From 8da1c2d2884c9d9ee1c82a9c4465d9f09fcff8a6 Mon Sep 17 00:00:00 2001 From: Sjaize Date: Wed, 1 Apr 2026 15:09:24 +0900 Subject: [PATCH] feat(concert): add location-based nearby concert search --- build.gradle.kts | 119 +++++++++--------- .../api/domain/artist/dto/ArtistMapper.kt | 2 + .../artist/dto/ConcertSearchResultDto.kt | 8 ++ .../repository/ArtiProfileRepository.kt | 5 +- .../concert/controller/ConcertController.kt | 13 ++ .../api/domain/concert/dto/ConcertMapper.kt | 8 ++ .../concert/dto/SubscribedConcertDto.kt | 5 + .../dto/request/ConcertCreateRequest.kt | 8 ++ .../dto/request/ConcertUpdateRequest.kt | 8 ++ .../dto/response/ConcertDetailResponse.kt | 5 + .../api/domain/concert/model/Concert.kt | 4 + .../concert/repository/ConcertRepository.kt | 104 ++++++++++++++- .../domain/concert/service/ConcertService.kt | 10 +- .../service/dto/CreateConcertCommand.kt | 3 + .../service/dto/UpdateConcertCommand.kt | 3 + .../api/domain/concert/table/ConcertTable.kt | 3 + .../api/global/config/DatabaseConfig.kt | 3 + src/main/resources/application.yml | 7 -- src/main/resources/db/migration/V1__init.sql | 29 ----- src/main/resources/db/migration/V2__init.sql | 29 ----- src/main/resources/db/migration/V3__init.sql | 112 ----------------- src/test/resources/application-test.yml | 4 - 22 files changed, 245 insertions(+), 247 deletions(-) delete mode 100644 src/main/resources/db/migration/V1__init.sql delete mode 100644 src/main/resources/db/migration/V2__init.sql delete mode 100644 src/main/resources/db/migration/V3__init.sql diff --git a/build.gradle.kts b/build.gradle.kts index 8654b17..b628bd4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -53,83 +53,78 @@ dependencies { testImplementation("com.h2database:h2:2.2.224") testRuntimeOnly("org.junit.platform:junit-platform-launcher") -// Flyway 핵심 라이브러리 - implementation("org.flywaydb:flyway-core") - // PostgreSQL 전용 의존성 - implementation("org.flywaydb:flyway-database-postgresql") -} - -kotlin { - compilerOptions { - freeCompilerArgs.addAll( - "-Xjsr305=strict", - "-Xjvm-default=all" - ) - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + kotlin { + compilerOptions { + freeCompilerArgs.addAll( + "-Xjsr305=strict", + "-Xjvm-default=all" + ) + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } } -} -tasks.withType { - useJUnitPlatform() + tasks.withType { + useJUnitPlatform() -} + } -tasks.test { - // 테스트 클래스 1개당 새로운 JVM 프로세스를 띄움 (완전 격리) - forkEvery = 1 + tasks.test { + // 테스트 클래스 1개당 새로운 JVM 프로세스를 띄움 (완전 격리) + forkEvery = 1 - // 또는 병렬 실행을 꺼서 하나씩 순차적으로 실행되게 보장 - maxParallelForks = 1 + // 또는 병렬 실행을 꺼서 하나씩 순차적으로 실행되게 보장 + maxParallelForks = 1 - systemProperty("kotest.framework.classpath.scanning.autoscan.disable", "true") - // JUnit HTML 리포트는 기본적으로 생성됩니다 - reports { - html.required.set(true) - junitXml.required.set(true) + systemProperty("kotest.framework.classpath.scanning.autoscan.disable", "true") + // JUnit HTML 리포트는 기본적으로 생성됩니다 + reports { + html.required.set(true) + junitXml.required.set(true) + } + finalizedBy(tasks.jacocoTestReport) // 테스트 실행 후 JaCoCo 리포트 생성 } - finalizedBy(tasks.jacocoTestReport) // 테스트 실행 후 JaCoCo 리포트 생성 -} // JaCoCo 설정 -jacoco { - toolVersion = "0.8.11" -} + jacoco { + toolVersion = "0.8.11" + } -tasks.jacocoTestReport { - dependsOn(tasks.test) // test 작업 후 실행 - reports { - xml.required.set(true) - csv.required.set(false) - html.required.set(true) - html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/test/html")) + tasks.jacocoTestReport { + dependsOn(tasks.test) // test 작업 후 실행 + reports { + xml.required.set(true) + csv.required.set(false) + html.required.set(true) + html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/test/html")) + } + // 리포트에 포함할 소스 파일 지정 + classDirectories.setFrom( + files(classDirectories.files.map { + fileTree(it) { + exclude( + "**/config/**", + "**/dto/**", + "**/exception/**", + "**/global/**", + "**/domain/**/dto/**", + "**/Application*" + ) + } + }) + ) } - // 리포트에 포함할 소스 파일 지정 - classDirectories.setFrom( - files(classDirectories.files.map { - fileTree(it) { - exclude( - "**/config/**", - "**/dto/**", - "**/exception/**", - "**/global/**", - "**/domain/**/dto/**", - "**/Application*" - ) - } - }) - ) -} // 테스트 커버리지 검증 작업 (선택사항) -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.80".toBigDecimal() // 최소 80% 커버리지 요구 + tasks.jacocoTestCoverageVerification { + dependsOn(tasks.jacocoTestReport) + violationRules { + rule { + limit { + minimum = "0.80".toBigDecimal() // 최소 80% 커버리지 요구 + } } } - } + } } \ No newline at end of file diff --git a/src/main/kotlin/com/bandchu/api/domain/artist/dto/ArtistMapper.kt b/src/main/kotlin/com/bandchu/api/domain/artist/dto/ArtistMapper.kt index da23002..809f27c 100644 --- a/src/main/kotlin/com/bandchu/api/domain/artist/dto/ArtistMapper.kt +++ b/src/main/kotlin/com/bandchu/api/domain/artist/dto/ArtistMapper.kt @@ -60,6 +60,8 @@ fun Concert.toConcertSearchResultDto(): ConcertSearchResultDto = concertId = id, title = title, place = place, + latitude = latitude, + longitude = longitude, posterImageUrl = posterImageUrl?.toString() ) diff --git a/src/main/kotlin/com/bandchu/api/domain/artist/dto/ConcertSearchResultDto.kt b/src/main/kotlin/com/bandchu/api/domain/artist/dto/ConcertSearchResultDto.kt index 4946a51..96a9dcb 100644 --- a/src/main/kotlin/com/bandchu/api/domain/artist/dto/ConcertSearchResultDto.kt +++ b/src/main/kotlin/com/bandchu/api/domain/artist/dto/ConcertSearchResultDto.kt @@ -1,6 +1,8 @@ package com.bandchu.api.domain.artist.dto import io.swagger.v3.oas.annotations.media.Schema +import java.math.BigDecimal + @Schema(description = "앨범 상세 정보") data class ConcertSearchResultDto( @get:Schema(description = "공연명 이름", example = "데이먼스이어 단독콘서트") @@ -12,6 +14,12 @@ data class ConcertSearchResultDto( @get:Schema(description = "공연명 이름", example = "데이먼스이어 단독콘서트") val place: String, + @get:Schema(description = "위도") + val latitude: BigDecimal?, + + @get:Schema(description = "경도") + val longitude: BigDecimal?, + @get:Schema(description = "공연명 이름", example = "데이먼스이어 단독콘서트") val posterImageUrl: String? ) diff --git a/src/main/kotlin/com/bandchu/api/domain/artist/repository/ArtiProfileRepository.kt b/src/main/kotlin/com/bandchu/api/domain/artist/repository/ArtiProfileRepository.kt index f539730..6f30ce5 100644 --- a/src/main/kotlin/com/bandchu/api/domain/artist/repository/ArtiProfileRepository.kt +++ b/src/main/kotlin/com/bandchu/api/domain/artist/repository/ArtiProfileRepository.kt @@ -39,12 +39,15 @@ class ArtiProfileRepository { id = this[ConcertTable.id].value, title = this[ConcertTable.title], place = this[ConcertTable.place], + latitude = this[ConcertTable.latitude], + longitude = this[ConcertTable.longitude], posterImageUrl = this[ConcertTable.posterImageUrl]?.let { URI(it) }, information = this[ConcertTable.information], bookingUrl = this[ConcertTable.bookingUrl]?.let { URI(it) }, bookingSchedule = this[ConcertTable.bookingSchedule], createdAt = this[ConcertTable.createdAt], - artiProfileId = this[ConcertTable.arti_profile].value + artiProfileId = this[ConcertTable.arti_profile].value, + viewCount = this[ConcertTable.viewCount] ) private fun ResultRow.toSnsLinkDomain(): SnsLink = diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/controller/ConcertController.kt b/src/main/kotlin/com/bandchu/api/domain/concert/controller/ConcertController.kt index 4dcacdc..74af667 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/controller/ConcertController.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/controller/ConcertController.kt @@ -24,6 +24,19 @@ import org.springframework.web.bind.annotation.* class ConcertController( private val concertService: ConcertService ) { + @Operation(summary = "근처 공연 조회", description = "현재 위치 기준 10km 이내, 오늘 스케줄이 있는 공연을 공연 시각 순으로 반환") + @GetMapping("/nearby") + fun getNearby( + @RequestParam lat: Double, + @RequestParam lng: Double + ): ResponseEntity> { + val concerts = concertService.getNearbyConcerts(lat, lng) + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(concerts.toConcertListResponse())) + } + @Operation(summary = "모든 콘서트 정보 가져오기", description = "한 artist에 대한 모든 콘서트 정보 가져오기") @GetMapping("") fun getAll( diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/dto/ConcertMapper.kt b/src/main/kotlin/com/bandchu/api/domain/concert/dto/ConcertMapper.kt index cce73aa..79b702f 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/dto/ConcertMapper.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/dto/ConcertMapper.kt @@ -40,6 +40,8 @@ fun Concert.toConcertDetailResponse(): ConcertDetailResponse = concertId = id, title = title, place = place, + latitude = latitude, + longitude = longitude, posterImageUrl = posterImageUrl?.toString(), information = information, bookingUrl = bookingUrl?.toString(), @@ -54,6 +56,8 @@ fun Concert.toSubscribedConcertDto(): SubscribedConcertDto = concertId = this.id, title = this.title, place = this.place, + latitude = this.latitude, + longitude = this.longitude, bookingSchedule = this.bookingSchedule.toString(), bookingUrl = this.bookingUrl.toString(), posterImageUrl = this.posterImageUrl.toString(), @@ -92,6 +96,8 @@ fun ConcertCreateRequest.toCommand(): CreateConcertCommand = CreateConcertCommand( title = title, place = place, + latitude = latitude, + longitude = longitude, posterImageUrl = posterImageUrl?.let { URI(it) }, information = information, bookingUrl = bookingUrl?.let { URI(it) }, @@ -105,6 +111,8 @@ fun ConcertUpdateRequest.toCommand(concertId: Long): UpdateConcertCommand = concertId = concertId, title = title, place = place, + latitude = latitude, + longitude = longitude, posterImageUrl = posterImageUrl?.let { URI(it) }, information = information, bookingUrl = bookingUrl?.let { URI(it) }, diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/dto/SubscribedConcertDto.kt b/src/main/kotlin/com/bandchu/api/domain/concert/dto/SubscribedConcertDto.kt index a072255..a58ae2e 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/dto/SubscribedConcertDto.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/dto/SubscribedConcertDto.kt @@ -2,6 +2,7 @@ package com.bandchu.api.domain.concert.dto import com.bandchu.api.domain.concert.dto.response.ConcertScheduleResponse import io.swagger.v3.oas.annotations.media.Schema +import java.math.BigDecimal @Schema(description = "구독 중인 콘서트 정보") data class SubscribedConcertDto( @@ -11,6 +12,10 @@ data class SubscribedConcertDto( val title: String, @get:Schema(description = "콘서트 장소", example = "인천 인스파이어 아레나") val place: String, + @get:Schema(description = "위도") + val latitude: BigDecimal?, + @get:Schema(description = "경도") + val longitude: BigDecimal?, @get:Schema(description = "예매 url", example = "http://www.interpark.com") val bookingUrl: String?, @get:Schema(description = "콘서트 포스터 이미지 url", example = "http://www.concert.poster.com") diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertCreateRequest.kt b/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertCreateRequest.kt index 1aef7bd..d14068d 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertCreateRequest.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertCreateRequest.kt @@ -3,6 +3,8 @@ package com.bandchu.api.domain.concert.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank +import java.math.BigDecimal + @Schema(description = "콘서트 생성 요청") data class ConcertCreateRequest( @get:Schema(description = "공연명 이름", example = "데이먼스이어 단독콘서트") @@ -13,6 +15,12 @@ data class ConcertCreateRequest( @field:NotBlank("공연 장소를 입력하세요.") val place: String, + @get:Schema(description = "위도 (프론트 주소/지도 검색 결과)", example = "37.5665") + val latitude: BigDecimal? = null, + + @get:Schema(description = "경도 (프론트 주소/지도 검색 결과)", example = "126.9780") + val longitude: BigDecimal? = null, + @get:Schema(description = "콘서트 포스터 이미지 url", example = "http://www.co.kr") val posterImageUrl: String?, diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertUpdateRequest.kt b/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertUpdateRequest.kt index 9015c96..5a72c04 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertUpdateRequest.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/dto/request/ConcertUpdateRequest.kt @@ -3,6 +3,8 @@ package com.bandchu.api.domain.concert.dto.request import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.Valid import jakarta.validation.constraints.NotBlank +import java.math.BigDecimal + @Schema(description = "콘서트 정보 업데이트 요청") data class ConcertUpdateRequest( @get:Schema(description = "콘서트 이름", example = "실리카겔 단독 콘서트") @@ -13,6 +15,12 @@ data class ConcertUpdateRequest( @field:NotBlank("공연 장소를 입력하세요.") val place: String, + @get:Schema(description = "위도 (프론트 주소/지도 검색 결과)") + val latitude: BigDecimal? = null, + + @get:Schema(description = "경도 (프론트 주소/지도 검색 결과)") + val longitude: BigDecimal? = null, + @get:Schema(description = "콘서트 포스터 이미지 url", example = "http://poster.co.kr") val posterImageUrl: String?, diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/dto/response/ConcertDetailResponse.kt b/src/main/kotlin/com/bandchu/api/domain/concert/dto/response/ConcertDetailResponse.kt index 35ac7c4..78be74d 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/dto/response/ConcertDetailResponse.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/dto/response/ConcertDetailResponse.kt @@ -1,6 +1,7 @@ package com.bandchu.api.domain.concert.dto.response import io.swagger.v3.oas.annotations.media.Schema +import java.math.BigDecimal @Schema(description = "콘서트 세부 사항 응답") @@ -11,6 +12,10 @@ data class ConcertDetailResponse( val title: String, @get:Schema(description = "콘서트 장소", example = "인천 인스파이어 아레나") val place: String, + @get:Schema(description = "위도 (클라이언트가 등록 시 전달)", example = "37.5665") + val latitude: BigDecimal?, + @get:Schema(description = "경도 (클라이언트가 등록 시 전달)", example = "126.9780") + val longitude: BigDecimal?, @get:Schema(description = "포스터 이미지 url", example = "http://www.image.co,.kr") val posterImageUrl: String?, @get:Schema(description = "콘서트 관련 정보", example = "이번 콘서트는 올해 하반기 최대 규모 락 밴드 페스티벌입니다.") diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/model/Concert.kt b/src/main/kotlin/com/bandchu/api/domain/concert/model/Concert.kt index a7e5f68..12f6e3d 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/model/Concert.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/model/Concert.kt @@ -1,5 +1,6 @@ package com.bandchu.api.domain.concert.model +import java.math.BigDecimal import java.net.URI import java.time.OffsetDateTime @@ -7,11 +8,14 @@ data class Concert( val id: Long, val title: String, val place: String, + val latitude: BigDecimal?, + val longitude: BigDecimal?, val posterImageUrl: URI?, val information: String?, val bookingUrl: URI?, val bookingSchedule: OffsetDateTime?, val createdAt: OffsetDateTime, val artiProfileId: Long, + val viewCount: Long, val schedules: List = emptyList() ) diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/repository/ConcertRepository.kt b/src/main/kotlin/com/bandchu/api/domain/concert/repository/ConcertRepository.kt index bda6c49..1a8c069 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/repository/ConcertRepository.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/repository/ConcertRepository.kt @@ -13,11 +13,17 @@ import com.bandchu.api.domain.concert.table.ConcertTable import com.bandchu.api.domain.subscription.table.SubscriptionTable import com.bandchu.api.global.exception.BusinessException import com.bandchu.api.global.exception.ErrorCode +import org.jetbrains.exposed.v1.core.DoubleColumnType +import org.jetbrains.exposed.v1.datetime.KotlinOffsetDateTimeColumnType import org.jetbrains.exposed.v1.core.JoinType import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.less +import org.jetbrains.exposed.v1.core.plus import org.jetbrains.exposed.v1.jdbc.batchInsert import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insertAndGetId @@ -27,7 +33,9 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import org.jetbrains.exposed.v1.jdbc.update import org.springframework.stereotype.Repository import java.net.URI +import java.time.LocalDate import java.time.OffsetDateTime +import java.time.ZoneId import java.time.ZoneOffset @Repository @@ -38,12 +46,15 @@ class ConcertRepository { id = this[ConcertTable.id].value, title = this[ConcertTable.title], place = this[ConcertTable.place], + latitude = this[ConcertTable.latitude], + longitude = this[ConcertTable.longitude], posterImageUrl = this[ConcertTable.posterImageUrl]?.let { URI(it) }, information = this[ConcertTable.information], bookingUrl = this[ConcertTable.bookingUrl]?.let { URI(it) }, bookingSchedule = this[ConcertTable.bookingSchedule], createdAt = this[ConcertTable.createdAt], artiProfileId = this[ConcertTable.arti_profile].value, + viewCount = this[ConcertTable.viewCount], schedules = schedules ) @@ -67,7 +78,7 @@ class ConcertRepository { memberId = this[ArtiProfileTable.member]?.value ) - private fun findSchedulesByConcertId(concertId: Long): List = transaction { + private fun findSchedulesByConcertId(concertId: Long): List = ConcertScheduleTable .selectAll() .where { ConcertScheduleTable.concert eq concertId } @@ -76,7 +87,6 @@ class ConcertRepository { row.toConcertScheduleDomain(concertId) } .toList() - } fun createProcess(command: CreateConcertCommand, userId: Long): Concert = transaction { val artiProfileId = ArtiProfileTable @@ -90,11 +100,14 @@ class ConcertRepository { val concertId = ConcertTable.insertAndGetId { it[title] = command.title it[place] = command.place + it[latitude] = command.latitude + it[longitude] = command.longitude it[posterImageUrl] = command.posterImageUrl?.toString() it[information] = command.information it[bookingSchedule] = command.bookingSchedule it[bookingUrl] = command.bookingUrl?.toString() it[createdAt] = OffsetDateTime.now(ZoneOffset.UTC) + it[viewCount] = 0L it[arti_profile] = artiProfileId } @@ -137,6 +150,8 @@ class ConcertRepository { it[title] = command.title it[place] = command.place it[posterImageUrl] = command.posterImageUrl?.toString() + it[latitude] = command.latitude + it[longitude] = command.longitude it[information] = command.information it[bookingSchedule] = command.bookingSchedule it[bookingUrl] = command.bookingUrl?.toString() @@ -181,6 +196,12 @@ class ConcertRepository { ConcertTable.deleteWhere { ConcertTable.id eq concertId } } + fun incrementViewCount(concertId: Long): Int = transaction { + ConcertTable.update({ ConcertTable.id eq concertId }) { + it[viewCount] = ConcertTable.viewCount + 1L + } + } + fun getDetail(concertId: Long): Concert = transaction { val row = ConcertTable .selectAll() @@ -260,6 +281,85 @@ class ConcertRepository { } } + fun findNearbyConcerts(lat: Double, lng: Double): List = transaction { + val kst = ZoneId.of("Asia/Seoul") + val today = LocalDate.now(kst) + val todayStart = today.atStartOfDay(kst).toOffsetDateTime() + val tomorrowStart = today.plusDays(1).atStartOfDay(kst).toOffsetDateTime() + + val sql = """ + SELECT c.id, c.title, c.place, c.latitude, c.longitude, + c.poster_image_url, c.information, c.booking_url, c.booking_date, + c.created_at, c.view_count, c.arti_profile + FROM concerts c + JOIN ( + SELECT concert, MIN(date) AS earliest_today + FROM concert_schedule + WHERE date >= ? + AND date < ? + GROUP BY concert + ) cs_today ON cs_today.concert = c.id + WHERE c.latitude IS NOT NULL + AND c.longitude IS NOT NULL + AND earth_box(ll_to_earth(?, ?), 10000) @> ll_to_earth(c.latitude::float8, c.longitude::float8) + AND earth_distance(ll_to_earth(c.latitude::float8, c.longitude::float8), ll_to_earth(?, ?)) <= 10000 + ORDER BY cs_today.earliest_today ASC, c.view_count DESC + """.trimIndent() + + val doubleType = DoubleColumnType() + val tsType = KotlinOffsetDateTimeColumnType() + val concerts = exec( + sql, + args = listOf( + tsType to todayStart, + tsType to tomorrowStart, + doubleType to lat, + doubleType to lng, + doubleType to lat, + doubleType to lng, + ) + ) { rs -> + val result = mutableListOf() + while (rs.next()) { + result.add( + Concert( + id = rs.getLong("id"), + title = rs.getString("title"), + place = rs.getString("place"), + latitude = rs.getBigDecimal("latitude"), + longitude = rs.getBigDecimal("longitude"), + posterImageUrl = rs.getString("poster_image_url")?.let { URI(it) }, + information = rs.getString("information"), + bookingUrl = rs.getString("booking_url")?.let { URI(it) }, + bookingSchedule = rs.getObject("booking_date", OffsetDateTime::class.java), + createdAt = rs.getObject("created_at", OffsetDateTime::class.java), + artiProfileId = rs.getLong("arti_profile"), + viewCount = rs.getLong("view_count"), + schedules = emptyList() + ) + ) + } + result + } ?: emptyList() + + if (concerts.isEmpty()) return@transaction emptyList() + + val concertIds = concerts.map { it.id } + val todaySchedules = ConcertScheduleTable + .selectAll() + .where { + ConcertScheduleTable.concert.inList(concertIds) + .and(ConcertScheduleTable.date greaterEq todayStart) + .and(ConcertScheduleTable.date less tomorrowStart) + } + .map { row -> row.toConcertScheduleDomain(row[ConcertScheduleTable.concert].value) } + .groupBy { it.concertId } + + concerts.map { concert -> + concert.copy(schedules = todaySchedules[concert.id] ?: emptyList()) + } + } + fun getAllByArtist(artistId: Long): List = transaction { val concertRows = ConcertTable .selectAll() diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/service/ConcertService.kt b/src/main/kotlin/com/bandchu/api/domain/concert/service/ConcertService.kt index bbb0d46..be1cecf 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/service/ConcertService.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/service/ConcertService.kt @@ -14,14 +14,22 @@ import org.springframework.stereotype.Service @Service class ConcertService( - private val concertRepository: ConcertRepository + private val concertRepository: ConcertRepository, ) { + fun getNearbyConcerts(lat: Double, lng: Double): List { + return concertRepository.findNearbyConcerts(lat, lng) + } + fun getAllByArtist(artistId: Long): List { return concertRepository.getAllByArtist(artistId) } fun getDetail(concertId: Long): Concert { + val updated = concertRepository.incrementViewCount(concertId) + if (updated == 0) { + throw BusinessException(ErrorCode.CONCERT_NOT_FOUND) + } return concertRepository.getDetail(concertId) } diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/CreateConcertCommand.kt b/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/CreateConcertCommand.kt index 5d6ba70..774ec4f 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/CreateConcertCommand.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/CreateConcertCommand.kt @@ -1,5 +1,6 @@ package com.bandchu.api.domain.concert.service.dto +import java.math.BigDecimal import java.net.URI import java.time.OffsetDateTime @@ -7,6 +8,8 @@ data class CreateConcertCommand( val title: String, val place: String, val posterImageUrl: URI?, + val latitude: BigDecimal?, + val longitude: BigDecimal?, val information: String?, val bookingSchedule: OffsetDateTime?, val bookingUrl: URI?, diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/UpdateConcertCommand.kt b/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/UpdateConcertCommand.kt index 269aa8b..adafbfa 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/UpdateConcertCommand.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/service/dto/UpdateConcertCommand.kt @@ -1,5 +1,6 @@ package com.bandchu.api.domain.concert.service.dto +import java.math.BigDecimal import java.net.URI import java.time.OffsetDateTime @@ -7,6 +8,8 @@ data class UpdateConcertCommand( val concertId: Long, val title: String, val place: String, + val latitude : BigDecimal?, + val longitude : BigDecimal?, val posterImageUrl: URI?, val information: String?, val bookingSchedule: OffsetDateTime?, diff --git a/src/main/kotlin/com/bandchu/api/domain/concert/table/ConcertTable.kt b/src/main/kotlin/com/bandchu/api/domain/concert/table/ConcertTable.kt index 034b1c3..1d85d3b 100644 --- a/src/main/kotlin/com/bandchu/api/domain/concert/table/ConcertTable.kt +++ b/src/main/kotlin/com/bandchu/api/domain/concert/table/ConcertTable.kt @@ -8,11 +8,14 @@ import org.jetbrains.exposed.v1.datetime.timestampWithTimeZone object ConcertTable : LongIdTable("concerts") { val title = varchar("title", 30) val place = varchar("place", 20) + val latitude = decimal("latitude", 10, 7).nullable() + val longitude = decimal("longitude", 10, 7).nullable() val posterImageUrl = varchar("poster_image_url", 255).nullable() val information = text("information").nullable() val bookingUrl = varchar("booking_url", 255).nullable() val bookingSchedule = timestampWithTimeZone("booking_date").nullable() val createdAt = timestampWithTimeZone("created_at") + val viewCount = long("view_count").default(0L) val arti_profile = reference("arti_profile", ArtiProfileTable.id, onDelete = ReferenceOption.CASCADE) } \ No newline at end of file diff --git a/src/main/kotlin/com/bandchu/api/global/config/DatabaseConfig.kt b/src/main/kotlin/com/bandchu/api/global/config/DatabaseConfig.kt index 4477b85..cc85cb3 100644 --- a/src/main/kotlin/com/bandchu/api/global/config/DatabaseConfig.kt +++ b/src/main/kotlin/com/bandchu/api/global/config/DatabaseConfig.kt @@ -36,6 +36,9 @@ class DatabaseConfig( // 전체 테이블 생성 transaction { + exec("CREATE EXTENSION IF NOT EXISTS cube") + exec("CREATE EXTENSION IF NOT EXISTS earthdistance") + SchemaUtils.create( // member & subscription MemberTable, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 528050c..4af2535 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,13 +8,6 @@ spring: password: ${DB_PASSWORD} driver-class-name: org.postgresql.Driver - # 🔴 아래 flyway와 jpa 설정이 spring 하위로 정확히 정렬되어야 합니다. - flyway: - enabled: true - baseline-on-migrate: true # 기존 DB가 있어도 기준점을 잡고 시작함 - locations: classpath:db/migration - schemas: public - jpa: hibernate: ddl-auto: validate diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql deleted file mode 100644 index f76c9ce..0000000 --- a/src/main/resources/db/migration/V1__init.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 1. 회원(Member) 테이블 생성 -CREATE TABLE member ( - id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL UNIQUE, - password VARCHAR(255) NOT NULL, - nickname VARCHAR(100) NOT NULL, - role VARCHAR(20) NOT NULL, -- FAN, ARTIST 저장 - google_id VARCHAR(255), -- OAuth용 Google ID - profile_image_url TEXT, - is_profile_completed BOOLEAN NOT NULL DEFAULT FALSE, -- 프로필 완료 여부 - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - --- 2. 구독(Subscription) 테이블 생성 -CREATE TABLE subscription ( - id SERIAL PRIMARY KEY, - -- 모델에는 생략되어 있지만, 실제 관계를 위해 외래키가 필요합니다 - subscriber_id BIGINT NOT NULL, -- 구독하는 사람 - artist_id BIGINT NOT NULL, -- 구독 대상 (아티스트) - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT fk_subscriber FOREIGN KEY (subscriber_id) REFERENCES member(id) ON DELETE CASCADE, - CONSTRAINT fk_artist FOREIGN KEY (artist_id) REFERENCES member(id) ON DELETE CASCADE, - CONSTRAINT unique_subscription UNIQUE (subscriber_id, artist_id) -); - --- 인덱스 추가 (조회 성능 향상) -CREATE INDEX idx_member_email ON member(email); -CREATE INDEX idx_member_google_id ON member(google_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V2__init.sql b/src/main/resources/db/migration/V2__init.sql deleted file mode 100644 index f570446..0000000 --- a/src/main/resources/db/migration/V2__init.sql +++ /dev/null @@ -1,29 +0,0 @@ --- 1. 앨범(Album) 테이블 생성 -CREATE TABLE album ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - release_date TIMESTAMP WITH TIME ZONE NOT NULL, - cover_image_url TEXT, - description TEXT, - artist_profile_id BIGINT NOT NULL, -- 아티스트 회원 ID 연결 - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - -- Member 테이블의 아티스트와 외래키 연결 - CONSTRAINT fk_album_artist FOREIGN KEY (artist_profile_id) REFERENCES member(id) ON DELETE CASCADE -); - --- 2. 트랙(Track) 테이블 생성 -CREATE TABLE track ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(255) NOT NULL, - url TEXT NOT NULL, - album_id BIGINT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - - -- Album 테이블과 외래키 연결 - CONSTRAINT fk_track_album FOREIGN KEY (album_id) REFERENCES album(id) ON DELETE CASCADE -); - --- 인덱스 추가 (조회 성능 향상) -CREATE INDEX idx_album_artist ON album(artist_profile_id); -CREATE INDEX idx_track_album ON track(album_id); \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__init.sql b/src/main/resources/db/migration/V3__init.sql deleted file mode 100644 index f540cf6..0000000 --- a/src/main/resources/db/migration/V3__init.sql +++ /dev/null @@ -1,112 +0,0 @@ --- 1. 아티스트 프로필 (ArtiProfile) -CREATE TABLE artist_profile ( - id BIGSERIAL PRIMARY KEY, - artist_name VARCHAR(100) NOT NULL, - description TEXT, - profile_image_url TEXT, - member_id BIGINT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_artist_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE -); - --- 아티스트 장르 (Enum List 대응) -CREATE TABLE artist_genre ( - id BIGSERIAL PRIMARY KEY, - artist_profile_id BIGINT NOT NULL, - genre VARCHAR(20) NOT NULL, -- BALLAD, ROCK 등 - CONSTRAINT fk_genre_artist FOREIGN KEY (artist_profile_id) REFERENCES artist_profile(id) ON DELETE CASCADE -); - --- 2. 콘서트 (Concert) -CREATE TABLE concert ( - id BIGSERIAL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - place VARCHAR(255) NOT NULL, - poster_image_url TEXT, - information TEXT, - booking_url TEXT, - booking_schedule TIMESTAMP WITH TIME ZONE, - artist_profile_id BIGINT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_concert_artist FOREIGN KEY (artist_profile_id) REFERENCES artist_profile(id) ON DELETE CASCADE -); - --- 콘서트 일정 (ConcertSchedule) -CREATE TABLE concert_schedule ( - id BIGSERIAL PRIMARY KEY, - concert_id BIGINT NOT NULL, - concert_date TIMESTAMP WITH TIME ZONE NOT NULL, - CONSTRAINT fk_schedule_concert FOREIGN KEY (concert_id) REFERENCES concert(id) ON DELETE CASCADE -); - --- 3. 채팅 (Chat) -CREATE TABLE chat_room ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR(100), - room_type VARCHAR(20) NOT NULL, -- DIRECT, GROUP - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE member_chat_room ( - id BIGSERIAL PRIMARY KEY, - room_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - role VARCHAR(20) DEFAULT 'MEMBER', - last_read_message_id BIGINT, - joined_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_chat_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE, - CONSTRAINT fk_chat_room FOREIGN KEY (room_id) REFERENCES chat_room(id) ON DELETE CASCADE -); - --- 4. 친구 (Friend) -CREATE TABLE friends ( - friend_id BIGSERIAL PRIMARY KEY, - sender_id BIGINT NOT NULL, - receiver_id BIGINT NOT NULL, - status VARCHAR(10) DEFAULT 'PENDING', -- PENDING, ACCEPTED - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_friend_sender FOREIGN KEY (sender_id) REFERENCES member(id) ON DELETE CASCADE, - CONSTRAINT fk_friend_receiver FOREIGN KEY (receiver_id) REFERENCES member(id) ON DELETE CASCADE, - CONSTRAINT unique_friend_request UNIQUE (sender_id, receiver_id) -); - --- 5. 게시글 및 댓글 (Posts) -CREATE TABLE posts ( - post_id BIGSERIAL PRIMARY KEY, - member_id BIGINT NOT NULL, - post_type VARCHAR(20) NOT NULL, -- FREE, MARKET, ARTIST 등 - title VARCHAR(100) NOT NULL, - content TEXT NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_post_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE -); - -CREATE TABLE comment ( - comment_id BIGSERIAL PRIMARY KEY, - post_id BIGINT NOT NULL, - member_id BIGINT NOT NULL, - content VARCHAR(500) NOT NULL, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_comment_post FOREIGN KEY (post_id) REFERENCES posts(post_id) ON DELETE CASCADE, - CONSTRAINT fk_comment_member FOREIGN KEY (member_id) REFERENCES member(id) ON DELETE CASCADE -); - -CREATE TABLE media ( - media_id BIGSERIAL PRIMARY KEY, - post_id BIGINT NOT NULL, - s3_url VARCHAR(500) NOT NULL, - s3_file_size BIGINT, - created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_media_post FOREIGN KEY (post_id) REFERENCES posts(post_id) ON DELETE CASCADE -); - -CREATE TABLE report ( - report_id BIGSERIAL PRIMARY KEY, - post_id BIGINT NOT NULL, - reason VARCHAR(50) NOT NULL, - report_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_report_post FOREIGN KEY (post_id) REFERENCES posts(post_id) ON DELETE CASCADE -); \ No newline at end of file diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 7f766e9..0610b71 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,10 +9,6 @@ spring: username: sa password: "" - # 🔴 테스트 시에는 Flyway가 H2에서 돌 수 있게 하거나 잠시 끕니다. - flyway: - enabled: false - sql: init: mode: never