diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index d613df2..60f005f 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,16 +1,18 @@ name: Deploy to Development on: - push: - branches: - - develop + workflow_dispatch: + +concurrency: + group: deploy-weski-${{ github.ref }} + cancel-in-progress: true jobs: deploy-dev: runs-on: ubuntu-latest steps: - - name: Check out code + - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 17 @@ -19,28 +21,18 @@ jobs: java-version: '17' distribution: 'temurin' - - name: Cache Gradle dependencies - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Setup Gradle (cache) + uses: gradle/actions/setup-gradle@v4 - name: Run tests run: ./gradlew test - - name: Build application - run: ./gradlew clean build -x test + - name: Install Railway CLI + run: npm install -g @railway/cli - name: Deploy to Railway Dev - uses: railwayapp/cli@v3 - with: - command: up --service WeSki-server-dev env: - RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} \ No newline at end of file + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_DEV }} + SPRING_PROFILES_ACTIVE: dev + SWAGGER_SERVER_URL_DEV: ${{ secrets.SWAGGER_SERVER_URL_DEV }} + run: railway up --detach --service ${{ secrets.RAILWAY_SERVICE_ID_DEV }} diff --git a/.github/workflows/deploy-frontend-dev.yml b/.github/workflows/deploy-frontend-dev.yml new file mode 100644 index 0000000..9b62297 --- /dev/null +++ b/.github/workflows/deploy-frontend-dev.yml @@ -0,0 +1,52 @@ +name: Deploy Frontend to Development + +on: + workflow_dispatch: + +concurrency: + group: deploy-frontend-dev-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy-frontend-dev: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: 'weski-admin/package-lock.json' + + - name: Install dependencies + working-directory: weski-admin + run: npm ci + + - name: Type check + working-directory: weski-admin + run: npm run type-check + + - name: Lint + working-directory: weski-admin + run: npm run lint + + - name: Build application + working-directory: weski-admin + run: npm run build + env: + NEXT_PUBLIC_API_URL: ${{ secrets.BACKEND_URL_DEV }} + RAILWAY_ENVIRONMENT: development + NODE_ENV: production + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway Dev + working-directory: weski-admin + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_DEV }} + run: railway up --detach --service ${{ secrets.RAILWAY_SERVICE_ID_FRONTEND_DEV }} diff --git a/.github/workflows/deploy-frontend-prod.yml b/.github/workflows/deploy-frontend-prod.yml new file mode 100644 index 0000000..e634578 --- /dev/null +++ b/.github/workflows/deploy-frontend-prod.yml @@ -0,0 +1,52 @@ +name: Deploy Frontend to Production + +on: + workflow_dispatch: + +concurrency: + group: deploy-frontend-prod-${{ github.ref }} + cancel-in-progress: true + +jobs: + deploy-frontend-prod: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: 'weski-admin/package-lock.json' + + - name: Install dependencies + working-directory: weski-admin + run: npm ci + + - name: Type check + working-directory: weski-admin + run: npm run type-check + + - name: Lint + working-directory: weski-admin + run: npm run lint + + - name: Build application + working-directory: weski-admin + run: npm run build + env: + NEXT_PUBLIC_API_URL: ${{ secrets.BACKEND_URL_PROD }} + RAILWAY_ENVIRONMENT: production + NODE_ENV: production + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway Prod + working-directory: weski-admin + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_PROD }} + run: railway up --detach --service ${{ secrets.RAILWAY_SERVICE_ID_FRONTEND_PROD }} diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 1d16f47..57a5e7e 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -1,16 +1,18 @@ name: Deploy to Production on: - push: - branches: - - main + workflow_dispatch: + +concurrency: + group: deploy-weski-${{ github.ref }} + cancel-in-progress: true jobs: deploy-prod: runs-on: ubuntu-latest steps: - - name: Check out code + - name: Checkout code uses: actions/checkout@v4 - name: Set up JDK 17 @@ -19,28 +21,18 @@ jobs: java-version: '17' distribution: 'temurin' - - name: Cache Gradle dependencies - uses: actions/cache@v3 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - - name: Grant execute permission for gradlew - run: chmod +x gradlew + - name: Setup Gradle (cache) + uses: gradle/actions/setup-gradle@v4 - name: Run tests run: ./gradlew test - - name: Build application - run: ./gradlew clean build -x test + - name: Install Railway CLI + run: npm install -g @railway/cli - name: Deploy to Railway Prod - uses: railwayapp/cli@v3 - with: - command: up --service WeSki-server env: - RAILWAY_TOKEN: ${{ secrets.RAILWAY_PROD_TOKEN }} \ No newline at end of file + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN_PROD }} + SPRING_PROFILES_ACTIVE: prod + SWAGGER_SERVER_URL_PROD: ${{ secrets.SWAGGER_SERVER_URL_PROD }} + run: railway up --detach --service ${{ secrets.RAILWAY_SERVICE_ID_PROD }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 63bb399..df24dad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,32 @@ -FROM openjdk:17-jdk-slim +## ===== Build stage ===== +FROM gradle:8.9-jdk17-alpine AS build +WORKDIR /workspace -# tzdata 패키지 설치 및 시간대 설정 -RUN apt-get update && apt-get install -y tzdata && \ - ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ - echo "Asia/Seoul" > /etc/timezone +# Gradle 캐시 최적화를 위해 래퍼/설정 먼저 복사 +COPY gradle gradle +COPY gradlew settings.gradle.kts build.gradle.kts ./ +RUN chmod +x gradlew -EXPOSE 8080 +# 소스 복사 후 빌드 (테스트는 CI에서 돌리므로 -x test) +COPY src ./src +RUN ./gradlew --no-daemon clean bootJar -x test + +## ===== Runtime stage ===== +FROM eclipse-temurin:17-jre-alpine + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring -ARG JAR_FILE=build/libs/*.jar -COPY ${JAR_FILE} weski-app.jar +WORKDIR /app +COPY --from=build /workspace/build/libs/*.jar /app/app.jar + +# 런타임 환경 +ENV TZ=Asia/Seoul \ + JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseG1GC" \ + SPRING_PROFILES_ACTIVE=dev + +# 실제 개방 포트는 Railway가 PORT로 지정할 수 있음 +EXPOSE 8080 -CMD java -Dserver.port=${PORT:-8080} \ - -Dspring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod} \ - -jar /weski-app.jar \ No newline at end of file +# 별도 스크립트 없이 바로 실행 +ENTRYPOINT [ "sh", "-c", "exec java $JAVA_OPTS -jar /app/app.jar" ] diff --git a/build.gradle.kts b/build.gradle.kts index 3e9db74..21d9d46 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") implementation("org.springframework.boot:spring-boot-starter-quartz") + implementation("org.springframework.boot:spring-boot-starter-batch") + implementation("org.jsoup:jsoup:1.17.2") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.mockito:mockito-core:4.11.0") diff --git a/src/main/kotlin/nexters/weski/app/version/AppVersionCheckService.kt b/src/main/kotlin/nexters/weski/app/version/AppVersionCheckService.kt index da3d384..ce11779 100644 --- a/src/main/kotlin/nexters/weski/app/version/AppVersionCheckService.kt +++ b/src/main/kotlin/nexters/weski/app/version/AppVersionCheckService.kt @@ -5,8 +5,8 @@ import org.springframework.stereotype.Service @Service class AppVersionCheckService { companion object { - const val IOS_MIN_VERSION = "3.0.2" - const val ANDROID_MIN_VERSION = "3.0.1" + const val IOS_MIN_VERSION = "3.1.2" + const val ANDROID_MIN_VERSION = "3.1.2" } fun getAppVersion( diff --git a/src/main/kotlin/nexters/weski/common/config/SwaggerConfig.kt b/src/main/kotlin/nexters/weski/common/config/SwaggerConfig.kt index f4ae3e4..ffd0c15 100644 --- a/src/main/kotlin/nexters/weski/common/config/SwaggerConfig.kt +++ b/src/main/kotlin/nexters/weski/common/config/SwaggerConfig.kt @@ -3,15 +3,33 @@ package nexters.weski.common.config import io.swagger.v3.oas.models.OpenAPI import io.swagger.v3.oas.models.info.Info import org.springdoc.core.models.GroupedOpenApi +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.web.filter.ForwardedHeaderFilter @Configuration class SwaggerConfig { + @Value("\${spring.profiles.active:local}") + private lateinit var activeProfile: String + + @Value("\${server.port:8080}") + private var serverPort: Int = 8080 + + @Value("\${app.swagger.server-url.dev:}") + private var devServerUrl: String = "" + + @Value("\${app.swagger.server-url.prod:}") + private var prodServerUrl: String = "" + + @Value("\${app.swagger.server-url.local:}") + private var localServerUrl: String = "" + @Bean - fun openAPI(): OpenAPI = - OpenAPI() + fun openAPI(): OpenAPI { + val serverUrl = getServerUrl() + + return OpenAPI() .info( Info() .title("We SKI API") @@ -21,9 +39,19 @@ class SwaggerConfig { listOf( io.swagger.v3.oas.models.servers .Server() - .url("http://223.130.154.51:8080"), + .url(serverUrl) + .description("${activeProfile.uppercase()} Environment"), ), ) + } + + private fun getServerUrl(): String = + when (activeProfile.lowercase()) { + "dev", "development" -> devServerUrl + "prod", "production" -> prodServerUrl + "local" -> localServerUrl.ifBlank { "http://localhost:$serverPort" } + else -> "http://localhost:$serverPort" + } @Bean fun userApi(): GroupedOpenApi = diff --git a/src/main/kotlin/nexters/weski/common/config/WebConfig.kt b/src/main/kotlin/nexters/weski/common/config/WebConfig.kt index 31e0d6f..66aa31c 100644 --- a/src/main/kotlin/nexters/weski/common/config/WebConfig.kt +++ b/src/main/kotlin/nexters/weski/common/config/WebConfig.kt @@ -1,20 +1,22 @@ package nexters.weski.common.config +import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Configuration import org.springframework.web.servlet.config.annotation.CorsRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration -class WebConfig : WebMvcConfigurer { +class WebConfig( + @Value("\${app.cors.allowed-origins}") + private val allowedOrigins: String, +) : WebMvcConfigurer { override fun addCorsMappings(registry: CorsRegistry) { + val origins = allowedOrigins.split(",").map { it.trim() }.toTypedArray() + registry .addMapping("/**") - .allowedOrigins( - "http://localhost:3000", - "http://localhost:5173", - "http://127.0.0.1:3000", - "http://127.0.0.1:5173", - ).allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") + .allowedOrigins(*origins) + .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) } diff --git a/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortController.kt b/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortController.kt index 1db3cd8..ac3a297 100644 --- a/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortController.kt +++ b/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortController.kt @@ -6,7 +6,6 @@ import io.swagger.v3.oas.annotations.tags.Tag import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.CrossOrigin import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -19,7 +18,6 @@ import org.springframework.web.bind.annotation.RestController @Tag(name = "Admin 스키장 관리 API", description = "관리자용 스키장 CRUD 작업") @RestController @RequestMapping("/api/admin/ski-resorts") -@CrossOrigin(origins = ["http://localhost:3000", "http://localhost:5173"]) // React 개발 서버용 class AdminSkiResortController( private val adminSkiResortService: AdminSkiResortService, ) { @@ -76,7 +74,7 @@ class AdminSkiResortController( ): ResponseEntity> { adminSkiResortService.deleteSkiResort(resortId) return ResponseEntity.ok( - ApiResponse.success("스키장이 성공적으로 삭제되었습니다"), + ApiResponse.success("스키장이 성공적으로 삭제되었습니다"), ) } @@ -85,7 +83,7 @@ class AdminSkiResortController( fun updateAllResortStatus(): ResponseEntity> { adminSkiResortService.updateAllResortStatus() return ResponseEntity.ok( - ApiResponse.success("모든 스키장 상태가 성공적으로 업데이트되었습니다"), + ApiResponse.success("모든 스키장 상태가 성공적으로 업데이트되었습니다"), ) } @@ -94,7 +92,7 @@ class AdminSkiResortController( fun updateAllSlopeCount(): ResponseEntity> { adminSkiResortService.updateAllSlopeCount() return ResponseEntity.ok( - ApiResponse.success("모든 스키장 슬로프 수가 성공적으로 업데이트되었습니다"), + ApiResponse.success("모든 스키장 슬로프 수가 성공적으로 업데이트되었습니다"), ) } } diff --git a/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortService.kt b/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortService.kt index f448864..51b8dff 100644 --- a/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortService.kt +++ b/src/main/kotlin/nexters/weski/ski/resort/AdminSkiResortService.kt @@ -82,25 +82,22 @@ class AdminSkiResortService( } } - val updatedSkiResort = - existingSkiResort.copy( - name = request.name ?: existingSkiResort.name, - status = request.status ?: existingSkiResort.status, - openingDate = request.openingDate ?: existingSkiResort.openingDate, - closingDate = request.closingDate ?: existingSkiResort.closingDate, - dayOperatingHours = request.dayOperatingHours ?: existingSkiResort.dayOperatingHours, - nightOperatingHours = request.nightOperatingHours ?: existingSkiResort.nightOperatingHours, - lateNightOperatingHours = request.lateNightOperatingHours ?: existingSkiResort.lateNightOperatingHours, - dawnOperatingHours = request.dawnOperatingHours ?: existingSkiResort.dawnOperatingHours, - midnightOperatingHours = request.midnightOperatingHours ?: existingSkiResort.midnightOperatingHours, - snowfallTime = request.snowfallTime ?: existingSkiResort.snowfallTime, - xCoordinate = request.xCoordinate ?: existingSkiResort.xCoordinate, - yCoordinate = request.yCoordinate ?: existingSkiResort.yCoordinate, - detailedAreaCode = request.detailedAreaCode ?: existingSkiResort.detailedAreaCode, - broadAreaCode = request.broadAreaCode ?: existingSkiResort.broadAreaCode, - ) - - val savedSkiResort = skiResortRepository.save(updatedSkiResort) + request.name?.let { existingSkiResort.name = it } + request.status?.let { existingSkiResort.status = it } + request.openingDate?.let { existingSkiResort.openingDate = it } + request.closingDate?.let { existingSkiResort.closingDate = it } + request.dayOperatingHours?.let { existingSkiResort.dayOperatingHours = it } + request.nightOperatingHours?.let { existingSkiResort.nightOperatingHours = it } + request.lateNightOperatingHours?.let { existingSkiResort.lateNightOperatingHours = it } + request.dawnOperatingHours?.let { existingSkiResort.dawnOperatingHours = it } + request.midnightOperatingHours?.let { existingSkiResort.midnightOperatingHours = it } + request.snowfallTime?.let { existingSkiResort.snowfallTime = it } + request.xCoordinate?.let { existingSkiResort.xCoordinate = it } + request.yCoordinate?.let { existingSkiResort.yCoordinate = it } + request.detailedAreaCode?.let { existingSkiResort.detailedAreaCode = it } + request.broadAreaCode?.let { existingSkiResort.broadAreaCode = it } + + val savedSkiResort = skiResortRepository.save(existingSkiResort) return AdminSkiResortResponse.fromEntity(savedSkiResort) } @@ -144,8 +141,8 @@ class AdminSkiResortService( } if (newStatus != skiResort.status) { - val updatedSkiResort = skiResort.copy(status = newStatus) - skiResortRepository.save(updatedSkiResort) + skiResort.status = newStatus + skiResortRepository.save(skiResort) } } } @@ -160,12 +157,9 @@ class AdminSkiResortService( val openingSlopeCount = slopeService.getOpeningSlopeCount(skiResort.resortId) if (totalSlopeCount != skiResort.totalSlopes || openingSlopeCount != skiResort.openSlopes) { - val updatedSkiResort = - skiResort.copy( - totalSlopes = totalSlopeCount, - openSlopes = openingSlopeCount, - ) - skiResortRepository.save(updatedSkiResort) + skiResort.totalSlopes = totalSlopeCount + skiResort.openSlopes = openingSlopeCount + skiResortRepository.save(skiResort) } } } diff --git a/src/main/kotlin/nexters/weski/ski/resort/SkiResort.kt b/src/main/kotlin/nexters/weski/ski/resort/SkiResort.kt index c444d2e..7851d8d 100644 --- a/src/main/kotlin/nexters/weski/ski/resort/SkiResort.kt +++ b/src/main/kotlin/nexters/weski/ski/resort/SkiResort.kt @@ -15,34 +15,44 @@ import java.time.LocalDate @Entity @Table(name = "ski_resorts") -data class SkiResort( +class SkiResort( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val resortId: Long = 0, - val name: String, + var name: String, @Enumerated(EnumType.STRING) - val status: ResortStatus, - val openingDate: LocalDate? = null, - val closingDate: LocalDate? = null, - val openSlopes: Int = 0, - val totalSlopes: Int = 0, - val dayOperatingHours: String? = null, - val nightOperatingHours: String? = null, - val lateNightOperatingHours: String? = null, - val dawnOperatingHours: String? = null, - val midnightOperatingHours: String? = null, - val snowfallTime: String? = null, - val xCoordinate: String, - val yCoordinate: String, - val xRealCoordinate: Double? = null, - val yRealCoordinate: Double? = null, - val detailedAreaCode: String, - val broadAreaCode: String, + var status: ResortStatus, + var openingDate: LocalDate? = null, + var closingDate: LocalDate? = null, + var openSlopes: Int = 0, + var totalSlopes: Int = 0, + var dayOperatingHours: String? = null, + var nightOperatingHours: String? = null, + var lateNightOperatingHours: String? = null, + var dawnOperatingHours: String? = null, + var midnightOperatingHours: String? = null, + var snowfallTime: String? = null, + var xCoordinate: String, + var yCoordinate: String, + var xRealCoordinate: Double? = null, + var yRealCoordinate: Double? = null, + var detailedAreaCode: String, + var broadAreaCode: String, @OneToMany(mappedBy = "skiResort", fetch = jakarta.persistence.FetchType.LAZY) val slopes: List = emptyList(), @OneToMany(mappedBy = "skiResort", fetch = jakarta.persistence.FetchType.LAZY) val webcams: List = emptyList(), -) : BaseEntity() +) : BaseEntity() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is SkiResort) return false + return resortId != 0L && resortId == other.resortId + } + + override fun hashCode(): Int = 31 + + override fun toString(): String = "SkiResort(resortId=$resortId, name='$name')" +} enum class ResortStatus { 운영중, diff --git a/src/main/kotlin/nexters/weski/ski/resort/SkiResortRepository.kt b/src/main/kotlin/nexters/weski/ski/resort/SkiResortRepository.kt index d266646..bc324b5 100644 --- a/src/main/kotlin/nexters/weski/ski/resort/SkiResortRepository.kt +++ b/src/main/kotlin/nexters/weski/ski/resort/SkiResortRepository.kt @@ -1,7 +1,9 @@ package nexters.weski.ski.resort import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +@Repository interface SkiResortRepository : JpaRepository { fun findAllByOrderByOpeningDateAsc(): List diff --git a/src/main/kotlin/nexters/weski/ski/resort/SkiResortService.kt b/src/main/kotlin/nexters/weski/ski/resort/SkiResortService.kt index d32640e..4e9ad68 100644 --- a/src/main/kotlin/nexters/weski/ski/resort/SkiResortService.kt +++ b/src/main/kotlin/nexters/weski/ski/resort/SkiResortService.kt @@ -31,9 +31,7 @@ class SkiResortService( fun getSkiResortAndWeather(resortId: Long): SkiResortResponseDto { val skiResort = - skiResortRepository - .findById(resortId) - .orElseThrow { IllegalArgumentException("해당 ID의 스키장이 존재하지 않습니다.") } + skiResortRepository.findById(resortId).orElseThrow { IllegalArgumentException("해당 ID의 스키장이 존재하지 않습니다.") } val currentWeather = currentWeatherRepository.findBySkiResortResortId(skiResort.resortId) val weeklyWeather = @@ -52,17 +50,14 @@ class SkiResortService( date: LocalDate, ) { val skiResort = - skiResortRepository - .findById(resortId) - .orElseThrow { IllegalArgumentException("해당 ID의 스키장이 존재하지 않습니다.") } + skiResortRepository.findById(resortId).orElseThrow { IllegalArgumentException("해당 ID의 스키장이 존재하지 않습니다.") } - val updatedSkiResort = - when (dateType) { - DateType.OPENING_DATE -> skiResort.copy(openingDate = date) - DateType.CLOSING_DATE -> skiResort.copy(closingDate = date) - } + when (dateType) { + DateType.OPENING_DATE -> skiResort.openingDate = date + DateType.CLOSING_DATE -> skiResort.closingDate = date + } - skiResortRepository.save(updatedSkiResort) + skiResortRepository.save(skiResort) } fun updateSkiResortStatus() { @@ -80,8 +75,8 @@ class SkiResortService( else -> ResortStatus.운영중 } - val updatedSkiResort = skiResort.copy(status = newStatus) - skiResortRepository.save(updatedSkiResort) + skiResort.status = newStatus + skiResortRepository.save(skiResort) } } @@ -90,8 +85,9 @@ class SkiResortService( skiResorts.forEach { skiResort -> val totalSlopeCount = slopeService.getTotalSlopeCount(skiResort.resortId) val openingSlopeCount = slopeService.getOpeningSlopeCount(skiResort.resortId) - val updatedSkiResort = skiResort.copy(totalSlopes = totalSlopeCount, openSlopes = openingSlopeCount) - skiResortRepository.save(updatedSkiResort) + skiResort.totalSlopes = totalSlopeCount + skiResort.openSlopes = openingSlopeCount + skiResortRepository.save(skiResort) } } } diff --git a/src/main/kotlin/nexters/weski/slope/AdminSlopeController.kt b/src/main/kotlin/nexters/weski/slope/AdminSlopeController.kt new file mode 100644 index 0000000..bc26d75 --- /dev/null +++ b/src/main/kotlin/nexters/weski/slope/AdminSlopeController.kt @@ -0,0 +1,33 @@ +package nexters.weski.slope + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import nexters.weski.ski.resort.ApiResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Admin 슬로프 관리 API", description = "관리자용 슬로프 수정") +@RestController +@RequestMapping("/api/admin/slopes") +class AdminSlopeController( + private val slopeService: SlopeService, +) { + @Operation(summary = "슬로프 정보 수정", description = "슬로프 정보를 수정합니다") + @PutMapping("/{slopeId}") + fun updateSlope( + @Parameter(description = "슬로프 ID", example = "1") + @PathVariable slopeId: Long, + @Valid @RequestBody request: UpdateSlopeRequest, + ): ResponseEntity> { + val updatedSlope = slopeService.updateSlope(slopeId, request) + return ResponseEntity.ok( + ApiResponse.success("슬로프 정보가 성공적으로 수정되었습니다", updatedSlope), + ) + } +} diff --git a/src/main/kotlin/nexters/weski/slope/Slope.kt b/src/main/kotlin/nexters/weski/slope/Slope.kt index 1c552f9..3ea121e 100644 --- a/src/main/kotlin/nexters/weski/slope/Slope.kt +++ b/src/main/kotlin/nexters/weski/slope/Slope.kt @@ -14,14 +14,14 @@ import nexters.weski.ski.resort.SkiResort @Entity @Table(name = "slopes") -data class Slope( +class Slope( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - val name: String, - val webcamNumber: Int? = null, + var name: String, + var webcamNumber: Int? = null, @Enumerated(EnumType.STRING) - val difficulty: DifficultyLevel, + var difficulty: DifficultyLevel, var isDayOperating: Boolean = false, var isNightOperating: Boolean = false, var isLateNightOperating: Boolean = false, @@ -30,7 +30,17 @@ data class Slope( @ManyToOne @JoinColumn(name = "resort_id") val skiResort: SkiResort, -) : BaseEntity() +) : BaseEntity() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Slope) return false + return id != 0L && id == other.id + } + + override fun hashCode(): Int = 31 + + override fun toString(): String = "Slope(id=$id, name='$name', difficulty=$difficulty)" +} enum class DifficultyLevel { 초급, diff --git a/src/main/kotlin/nexters/weski/slope/SlopeService.kt b/src/main/kotlin/nexters/weski/slope/SlopeService.kt index a7997fc..6f7bd64 100644 --- a/src/main/kotlin/nexters/weski/slope/SlopeService.kt +++ b/src/main/kotlin/nexters/weski/slope/SlopeService.kt @@ -42,4 +42,25 @@ class SlopeService( } slopeRepository.save(slope) } + + fun updateSlope( + slopeId: Long, + request: UpdateSlopeRequest, + ): SlopeDto { + val slope = + slopeRepository + .findById(slopeId) + .orElseThrow { Exception("Slope not found") } + + slope.name = request.name + slope.difficulty = request.difficulty + slope.webcamNumber = request.webcamNumber + slope.isDayOperating = request.isDayOperating + slope.isNightOperating = request.isNightOperating + slope.isLateNightOperating = request.isLateNightOperating + slope.isDawnOperating = request.isDawnOperating + slope.isMidnightOperating = request.isMidnightOperating + + return SlopeDto.fromEntity(slopeRepository.save(slope)) + } } diff --git a/src/main/kotlin/nexters/weski/slope/UpdateSlopeRequest.kt b/src/main/kotlin/nexters/weski/slope/UpdateSlopeRequest.kt new file mode 100644 index 0000000..1867648 --- /dev/null +++ b/src/main/kotlin/nexters/weski/slope/UpdateSlopeRequest.kt @@ -0,0 +1,16 @@ +package nexters.weski.slope + +import jakarta.validation.constraints.NotNull + +data class UpdateSlopeRequest( + @field:NotNull + val name: String, + @field:NotNull + val difficulty: DifficultyLevel, + val webcamNumber: Int?, + val isDayOperating: Boolean, + val isNightOperating: Boolean, + val isLateNightOperating: Boolean, + val isDawnOperating: Boolean, + val isMidnightOperating: Boolean, +) diff --git a/src/main/kotlin/nexters/weski/webcam/AdminWebcamController.kt b/src/main/kotlin/nexters/weski/webcam/AdminWebcamController.kt new file mode 100644 index 0000000..bd3affa --- /dev/null +++ b/src/main/kotlin/nexters/weski/webcam/AdminWebcamController.kt @@ -0,0 +1,33 @@ +package nexters.weski.webcam + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import nexters.weski.ski.resort.ApiResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Admin 웹캠 관리 API", description = "관리자용 웹캠 수정") +@RestController +@RequestMapping("/api/admin/webcams") +class AdminWebcamController( + private val webcamService: WebcamService, +) { + @Operation(summary = "웹캠 정보 수정", description = "웹캠 URL을 수정합니다") + @PutMapping("/{webcamId}") + fun updateWebcam( + @Parameter(description = "웹캠 ID", example = "1") + @PathVariable webcamId: Long, + @Valid @RequestBody request: UpdateWebcamRequest, + ): ResponseEntity> { + val updatedWebcam = webcamService.updateWebcam(webcamId, request) + return ResponseEntity.ok( + ApiResponse.success("웹캠 정보가 성공적으로 수정되었습니다", updatedWebcam), + ) + } +} diff --git a/src/main/kotlin/nexters/weski/webcam/UpdateWebcamRequest.kt b/src/main/kotlin/nexters/weski/webcam/UpdateWebcamRequest.kt new file mode 100644 index 0000000..007910e --- /dev/null +++ b/src/main/kotlin/nexters/weski/webcam/UpdateWebcamRequest.kt @@ -0,0 +1,7 @@ +package nexters.weski.webcam + +data class UpdateWebcamRequest( + val name: String?, + val description: String?, + val url: String?, +) diff --git a/src/main/kotlin/nexters/weski/webcam/Webcam.kt b/src/main/kotlin/nexters/weski/webcam/Webcam.kt index c284136..32664a8 100644 --- a/src/main/kotlin/nexters/weski/webcam/Webcam.kt +++ b/src/main/kotlin/nexters/weski/webcam/Webcam.kt @@ -12,15 +12,25 @@ import nexters.weski.ski.resort.SkiResort @Entity @Table(name = "webcams") -data class Webcam( +class Webcam( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) val id: Long = 0, - val name: String, + var name: String, val number: Int, - val description: String?, - val url: String, + var description: String?, + var url: String?, @ManyToOne @JoinColumn(name = "resort_id") val skiResort: SkiResort, -) : BaseEntity() +) : BaseEntity() { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Webcam) return false + return id != 0L && id == other.id + } + + override fun hashCode(): Int = 31 + + override fun toString(): String = "Webcam(id=$id, name='$name', url='$url')" +} diff --git a/src/main/kotlin/nexters/weski/webcam/WebcamDto.kt b/src/main/kotlin/nexters/weski/webcam/WebcamDto.kt index f58091c..6cefe38 100644 --- a/src/main/kotlin/nexters/weski/webcam/WebcamDto.kt +++ b/src/main/kotlin/nexters/weski/webcam/WebcamDto.kt @@ -1,6 +1,7 @@ package nexters.weski.webcam data class WebcamDto( + val id: Long, val name: String, val number: Int, val description: String?, @@ -10,6 +11,7 @@ data class WebcamDto( companion object { fun fromEntity(entity: Webcam): WebcamDto = WebcamDto( + id = entity.id, name = entity.name, number = entity.number, description = entity.description, diff --git a/src/main/kotlin/nexters/weski/webcam/WebcamService.kt b/src/main/kotlin/nexters/weski/webcam/WebcamService.kt new file mode 100644 index 0000000..299026c --- /dev/null +++ b/src/main/kotlin/nexters/weski/webcam/WebcamService.kt @@ -0,0 +1,29 @@ +package nexters.weski.webcam + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class WebcamService( + private val webcamRepository: WebcamRepository, +) { + @Transactional + fun updateWebcam( + webcamId: Long, + request: UpdateWebcamRequest, + ): WebcamDto { + val webcam = + webcamRepository.findById(webcamId).orElseThrow { + IllegalArgumentException("Webcam not found with id: $webcamId") + } + + // name은 non-nullable이므로 값이 제공된 경우에만 업데이트 + request.name?.let { webcam.name = it } + + // description과 url은 nullable이므로 request 값으로 업데이트 (null 포함) + webcam.description = request.description + webcam.url = request.url + + return WebcamDto.fromEntity(webcam) + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cdb388c..f281c32 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -37,4 +37,11 @@ tmap: key: ${TMAP_API_KEY} server: - port: ${PORT:8080} \ No newline at end of file + port: ${PORT:8080} + +app: + swagger: + server-url: + dev: ${SWAGGER_SERVER_URL_DEV} + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,https://weski-admin-dev-development.up.railway.app} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ae419eb..76c9b37 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -39,4 +39,11 @@ tmap: key: ${TMAP_API_KEY} server: - port: ${PORT:8080} \ No newline at end of file + port: ${PORT:8080} + +app: + swagger: + server-url: + local: ${SWAGGER_SERVER_URL_LOCAL:http://localhost:8080} + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 4a49e8d..14da856 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -37,4 +37,11 @@ tmap: key: ${TMAP_API_KEY} server: - port: ${PORT:8080} \ No newline at end of file + port: ${PORT:8080} + +app: + swagger: + server-url: + prod: ${SWAGGER_SERVER_URL_PROD:https://weski-server-production.up.railway.app} + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:https://weski-admin-prod-production.up.railway.app} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ce8a5ea --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,21 @@ +spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + +# 공통 설정 +logging: + level: + org.springframework.web: INFO + sun.rmi: INFO + +# Swagger 공통 설정 +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html + +# CORS 공통 설정 +app: + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173} diff --git a/src/test/kotlin/nexters/weski/ski/resort/SkiResortControllerTest.kt b/src/test/kotlin/nexters/weski/ski/resort/SkiResortControllerTest.kt index dd9ee2a..dc82d69 100644 --- a/src/test/kotlin/nexters/weski/ski/resort/SkiResortControllerTest.kt +++ b/src/test/kotlin/nexters/weski/ski/resort/SkiResortControllerTest.kt @@ -15,44 +15,49 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status -@WebMvcTest(SkiResortController::class) -@ComponentScan( - excludeFilters = [ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = [JpaConfig::class] - )] +@WebMvcTest( + controllers = [SkiResortController::class], + excludeFilters = [ + ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = [AdminSkiResortController::class, JpaConfig::class], + ), + ], ) -class SkiResortControllerTest @Autowired constructor( - private val mockMvc: MockMvc -) { +class SkiResortControllerTest + @Autowired + constructor( + private val mockMvc: MockMvc, + ) { + @MockkBean + lateinit var skiResortService: SkiResortService - @MockkBean - lateinit var skiResortService: SkiResortService + @Test + fun `GET api_ski-resorts should return list of ski resorts`() { + val currentWeather = SimpleCurrentWeatherDto(-1, "맑음") + val weeklyWeather = + listOf( + WeeklyWeatherDto("월", 5, -3, "맑음"), + WeeklyWeatherDto("화", 6, -2, "맑음"), + WeeklyWeatherDto("수", 7, -1, "맑음"), + WeeklyWeatherDto("목", 8, 0, "맑음"), + WeeklyWeatherDto("금", 9, 1, "맑음"), + WeeklyWeatherDto("토", 10, 2, "맑음"), + WeeklyWeatherDto("일", 11, 3, "맑음"), + ) + // Given + val skiResorts = + listOf( + SkiResortResponseDto(1, "스키장 A", ResortStatus.운영중.name, "미정", "미정", 3, currentWeather, weeklyWeather), + SkiResortResponseDto(2, "스키장 B", ResortStatus.운영중.name, "미정", "미정", 4, currentWeather, weeklyWeather), + ) + every { skiResortService.getAllSkiResortsAndWeather() } returns skiResorts - @Test - fun `GET api_ski-resorts should return list of ski resorts`() { - val currentWeather = SimpleCurrentWeatherDto(-1, "맑음") - val weeklyWeather = listOf( - WeeklyWeatherDto("월", 5, -3, "맑음"), - WeeklyWeatherDto("화", 6, -2, "맑음"), - WeeklyWeatherDto("수", 7, -1, "맑음"), - WeeklyWeatherDto("목", 8, 0, "맑음"), - WeeklyWeatherDto("금", 9, 1, "맑음"), - WeeklyWeatherDto("토", 10, 2, "맑음"), - WeeklyWeatherDto("일", 11, 3, "맑음") - ) - // Given - val skiResorts = listOf( - SkiResortResponseDto(1, "스키장 A", ResortStatus.운영중.name, "미정", "미정", 3, currentWeather, weeklyWeather), - SkiResortResponseDto(2, "스키장 B", ResortStatus.운영중.name, "미정", "미정", 4, currentWeather, weeklyWeather), - ) - every { skiResortService.getAllSkiResortsAndWeather() } returns skiResorts - - - // When & Then - mockMvc.perform(get("/api/ski-resorts")) - .andExpect(status().isOk) - .andExpect(jsonPath("$[0].name").value("스키장 A")) - .andExpect(jsonPath("$[1].name").value("스키장 B")) + // When & Then + mockMvc + .perform(get("/api/ski-resorts")) + .andExpect(status().isOk) + .andExpect(jsonPath("$[0].name").value("스키장 A")) + .andExpect(jsonPath("$[1].name").value("스키장 B")) + } } -} \ No newline at end of file diff --git a/src/test/kotlin/nexters/weski/ski/resort/SkiResortServiceTest.kt b/src/test/kotlin/nexters/weski/ski/resort/SkiResortServiceTest.kt index 82a77bf..022f811 100644 --- a/src/test/kotlin/nexters/weski/ski/resort/SkiResortServiceTest.kt +++ b/src/test/kotlin/nexters/weski/ski/resort/SkiResortServiceTest.kt @@ -2,56 +2,66 @@ package nexters.weski.ski.resort import io.mockk.every import io.mockk.mockk +import nexters.weski.slope.SlopeService import nexters.weski.weather.CurrentWeatherRepository import nexters.weski.weather.DailyWeatherRepository -import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class SkiResortServiceTest { - private val skiResortRepository: SkiResortRepository = mockk() private val currentWeatherRepository: CurrentWeatherRepository = mockk() private val dailyWeatherRepository: DailyWeatherRepository = mockk() - private val skiResortService = SkiResortService( - skiResortRepository, - currentWeatherRepository, - dailyWeatherRepository, - ) + private val slopeService: SlopeService = mockk() + private val skiResortService = + SkiResortService( + skiResortRepository, + currentWeatherRepository, + dailyWeatherRepository, + slopeService, + ) @Test fun `getAllSkiResorts should return list of SkiResortDto`() { // Given - val skiResorts = listOf( - SkiResort( - resortId = 1, - name = "스키장 A", - status = ResortStatus.운영중, - openingDate = null, - closingDate = null, - openSlopes = 3, - totalSlopes = 8, - xCoordinate = "12.0", - yCoordinate = "34.0", - detailedAreaCode = "11D20201", - broadAreaCode = "11D20000" - ), - SkiResort( - resortId = 2, - name = "스키장 B", - status = ResortStatus.운영중, - openingDate = null, - closingDate = null, - openSlopes = 3, - totalSlopes = 8, - xCoordinate = "12.0", - yCoordinate = "34.0", - detailedAreaCode = "11D20201", - broadAreaCode = "11D20000" + val skiResorts = + listOf( + SkiResort( + resortId = 1, + name = "스키장 A", + status = ResortStatus.운영중, + openingDate = null, + closingDate = null, + openSlopes = 3, + totalSlopes = 8, + xCoordinate = "12.0", + yCoordinate = "34.0", + detailedAreaCode = "11D20201", + broadAreaCode = "11D20000", + ), + SkiResort( + resortId = 2, + name = "스키장 B", + status = ResortStatus.운영중, + openingDate = null, + closingDate = null, + openSlopes = 3, + totalSlopes = 8, + xCoordinate = "12.0", + yCoordinate = "34.0", + detailedAreaCode = "11D20201", + broadAreaCode = "11D20000", + ), ) - ) every { skiResortRepository.findAllByOrderByOpeningDateAsc() } returns skiResorts every { currentWeatherRepository.findBySkiResortResortId(any()) } returns null - every { dailyWeatherRepository.findAllBySkiResortResortId(any()) } returns emptyList() + every { + dailyWeatherRepository.findBySkiResortAndForecastDateBetweenOrderByForecastDate( + any(), + any(), + any(), + ) + } returns emptyList() // When val result = skiResortService.getAllSkiResortsAndWeather() @@ -61,4 +71,4 @@ class SkiResortServiceTest { assertEquals("스키장 A", result[0].name) assertEquals("스키장 B", result[1].name) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/nexters/weski/snowmaker/SnowMakerControllerTest.kt b/src/test/kotlin/nexters/weski/snowmaker/SnowMakerControllerTest.kt index 9531585..455805e 100644 --- a/src/test/kotlin/nexters/weski/snowmaker/SnowMakerControllerTest.kt +++ b/src/test/kotlin/nexters/weski/snowmaker/SnowMakerControllerTest.kt @@ -1,6 +1,5 @@ package nexters.weski.snowmaker - import com.ninjasquad.springmockk.MockkBean import io.mockk.Runs import io.mockk.every @@ -12,46 +11,54 @@ import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.context.annotation.ComponentScan import org.springframework.context.annotation.FilterType import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @WebMvcTest(SnowMakerController::class) @ComponentScan( - excludeFilters = [ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = [JpaConfig::class] - )] + excludeFilters = [ + ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = [JpaConfig::class], + ), + ], ) -class SnowMakerControllerTest @Autowired constructor( - private val mockMvc: MockMvc -) { - - @MockkBean - lateinit var snowMakerService: SnowMakerService +class SnowMakerControllerTest + @Autowired + constructor( + private val mockMvc: MockMvc, + ) { + @MockkBean + lateinit var snowMakerService: SnowMakerService - @Test - fun `GET api_snow-maker_resortId should return snow Maker data`() { - // Given - val resortId = 1L - val snowMakerDto = SnowMakerDto(resortId, 100, 80, "좋음") - every { snowMakerService.getSnowMaker(resortId) } returns snowMakerDto + @Test + fun `GET api_snow-maker_resortId should return snow Maker data`() { + // Given + val resortId = 1L + val snowMakerDto = SnowMakerDto(resortId, 100, 80, "좋음") + every { snowMakerService.getSnowMaker(resortId) } returns snowMakerDto - // When & Then - mockMvc.perform(get("/api/snow-maker/$resortId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.totalVotes").value(100)) - .andExpect(jsonPath("$.status").value("좋음")) - } + // When & Then + mockMvc + .perform(get("/api/snow-maker/$resortId")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.totalVotes").value(100)) + .andExpect(jsonPath("$.status").value("좋음")) + } - @Test - fun `POST api_snow-maker_resortId_vote should vote successfully`() { - // Given - val resortId = 1L - every { snowMakerService.voteSnowMaker(any(), any()) } just Runs + @Test + fun `POST api_snow-maker_resortId_vote should vote successfully`() { + // Given + val resortId = 1L + every { snowMakerService.voteSnowMaker(any(), any()) } just Runs - // When & Then - mockMvc.perform(post("/api/snow-maker/$resortId/vote") - .param("isPositive", "true")) - .andExpect(status().isOk) + // When & Then + mockMvc + .perform( + post("/api/snow-maker/$resortId/vote") + .param("isPositive", "true"), + ).andExpect(status().isOk) + } } -} diff --git a/src/test/kotlin/nexters/weski/weather/WeatherControllerTest.kt b/src/test/kotlin/nexters/weski/weather/WeatherControllerTest.kt index f8594d6..65e9887 100644 --- a/src/test/kotlin/nexters/weski/weather/WeatherControllerTest.kt +++ b/src/test/kotlin/nexters/weski/weather/WeatherControllerTest.kt @@ -15,34 +15,39 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @WebMvcTest(WeatherController::class) @ComponentScan( - excludeFilters = [ComponentScan.Filter( - type = FilterType.ASSIGNABLE_TYPE, - classes = [JpaConfig::class, WeatherUpdateService::class] - )] + excludeFilters = [ + ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = [JpaConfig::class, WeatherUpdateService::class], + ), + ], ) -class WeatherControllerTest @Autowired constructor( - private val mockMvc: MockMvc -) { +class WeatherControllerTest + @Autowired + constructor( + private val mockMvc: MockMvc, + ) { + @MockkBean + lateinit var weatherService: WeatherService - @MockkBean - lateinit var weatherService: WeatherService + @Test + fun `GET api_weather_resortId should return weather data`() { + // Given + val resortId = 1L + val weatherDto = + WeatherDto( + resortId, + CurrentWeatherDto(-5, -2, -8, -10, "눈이 내리고 있습니다.", "눈"), + listOf(), + listOf(), + ) + every { weatherService.getWeatherByResortId(resortId) } returns weatherDto - @Test - fun `GET api_weather_resortId should return weather data`() { - // Given - val resortId = 1L - val weatherDto = WeatherDto( - resortId, - CurrentWeatherDto(-5, -2, -8, -10, "눈이 내리고 있습니다.", "눈"), - listOf(), - listOf() - ) - every { weatherService.getWeatherByResortId(resortId) } returns weatherDto - - // When & Then - mockMvc.perform(get("/api/weather/$resortId")) - .andExpect(status().isOk) - .andExpect(jsonPath("$.currentWeather.temperature").value(-5)) - .andExpect(jsonPath("$.currentWeather.condition").value("눈")) + // When & Then + mockMvc + .perform(get("/api/weather/$resortId")) + .andExpect(status().isOk) + .andExpect(jsonPath("$.currentWeather.temperature").value(-5)) + .andExpect(jsonPath("$.currentWeather.condition").value("눈")) + } } -} \ No newline at end of file diff --git a/src/test/kotlin/nexters/weski/weather/WeatherServiceTest.kt b/src/test/kotlin/nexters/weski/weather/WeatherServiceTest.kt index d53358e..2e3114a 100644 --- a/src/test/kotlin/nexters/weski/weather/WeatherServiceTest.kt +++ b/src/test/kotlin/nexters/weski/weather/WeatherServiceTest.kt @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Test class WeatherServiceTest { - private val currentWeatherRepository: CurrentWeatherRepository = mockk() private val hourlyWeatherRepository: HourlyWeatherRepository = mockk() private val dailyWeatherRepository: DailyWeatherRepository = mockk() @@ -20,17 +19,48 @@ class WeatherServiceTest { fun `getWeatherByResortId should return WeatherDto`() { // Given val resortId = 1L - val skiResort = SkiResort( - resortId, "스키장 A", ResortStatus.운영중, null, null, 5, 10, - "09:00 ~ 17:00", "18:00 ~ 22:00", "22:00 ~ 24:00", "06:00 ~ 09:00", "00:00 ~ 02:00", - "눈이 내리고 있습니다.", "37.123456", "127.123456", "123456", "123456" - ) - val currentWeather = CurrentWeather( - resortId, -5, -2, -8, -10, "눈이 내리고 있습니다.", "눈", skiResort - ) + val skiResort = + SkiResort( + resortId = resortId, + name = "스키장 A", + status = ResortStatus.운영중, + openingDate = null, + closingDate = null, + openSlopes = 5, + totalSlopes = 10, + dayOperatingHours = "09:00 ~ 17:00", + nightOperatingHours = "18:00 ~ 22:00", + lateNightOperatingHours = "22:00 ~ 24:00", + dawnOperatingHours = "06:00 ~ 09:00", + midnightOperatingHours = "00:00 ~ 02:00", + snowfallTime = "눈이 내리고 있습니다.", + xCoordinate = "37.123456", + yCoordinate = "127.123456", + xRealCoordinate = 123456.0, + yRealCoordinate = 123456.0, + detailedAreaCode = "Test", + broadAreaCode = "Test", + ) + val currentWeather = + CurrentWeather( + resortId, + -5, + -2, + -8, + -10, + "눈이 내리고 있습니다.", + "눈", + skiResort, + ) every { currentWeatherRepository.findBySkiResortResortId(resortId) } returns currentWeather every { hourlyWeatherRepository.findBySkiResortResortId(resortId) } returns listOf() - every { dailyWeatherRepository.findAllBySkiResortResortId(resortId) } returns listOf() + every { + dailyWeatherRepository.findAllBySkiResortResortIdAndForecastDateBetweenOrderByForecastDate( + resortId, + any(), + any(), + ) + } returns listOf() // When val result = weatherService.getWeatherByResortId(resortId) @@ -39,4 +69,4 @@ class WeatherServiceTest { assertNotNull(result) assertEquals(-5, result?.currentWeather?.temperature) } -} \ No newline at end of file +} diff --git a/weski-admin/.prettierignore b/weski-admin/.prettierignore new file mode 100644 index 0000000..97b005e --- /dev/null +++ b/weski-admin/.prettierignore @@ -0,0 +1,10 @@ +# build / deps +node_modules + +# generated +*.snap +coverage + +# non-code +*.md +*.json \ No newline at end of file diff --git a/weski-admin/.prettierrc b/weski-admin/.prettierrc new file mode 100644 index 0000000..b0712ea --- /dev/null +++ b/weski-admin/.prettierrc @@ -0,0 +1,9 @@ +{ + "tabWidth": 2, + "useTabs": false, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "arrowParens": "always" +} \ No newline at end of file diff --git a/weski-admin/.railwayignore b/weski-admin/.railwayignore new file mode 100644 index 0000000..834a30a --- /dev/null +++ b/weski-admin/.railwayignore @@ -0,0 +1,30 @@ +# Railway 배포 시 제외할 파일들 +node_modules/ +.next/ +.git/ +.env.local +.env.*.local + +# 개발 관련 파일들 +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE 파일들 +.vscode/ +.idea/ +*.swp +*.swo + +# OS 관련 파일들 +.DS_Store +Thumbs.db + +# 테스트 및 커버리지 +coverage/ +.nyc_output/ + +# 캐시 파일들 +.cache/ +.parcel-cache/ diff --git a/weski-admin/next.config.js b/weski-admin/next.config.js index fdceac8..23447ee 100644 --- a/weski-admin/next.config.js +++ b/weski-admin/next.config.js @@ -1,46 +1,80 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, - - // API 프록시 설정 (개발 환경에서 CORS 문제 해결) + + // API 프록시 설정 (환경별 백엔드 URL 자동 설정) async rewrites() { + // 환경별 백엔드 서버 URL 설정 + let backendUrl + + if (process.env.NEXT_PUBLIC_API_URL) { + // 명시적으로 설정된 API URL이 있으면 사용 + backendUrl = process.env.NEXT_PUBLIC_API_URL + } else if (process.env.RAILWAY_ENVIRONMENT === 'production') { + // Railway 프로덕션 환경 + backendUrl = process.env.RAILWAY_PRIVATE_DOMAIN + ? `http://${process.env.RAILWAY_PRIVATE_DOMAIN}` + : 'https://weski-server-production.up.railway.app' + } else if (process.env.RAILWAY_ENVIRONMENT === 'development') { + // Railway 개발 환경 + backendUrl = process.env.RAILWAY_PRIVATE_DOMAIN + ? `http://${process.env.RAILWAY_PRIVATE_DOMAIN}` + : 'https://weski-server-dev-development.up.railway.app' + } else { + // 로컬 개발 환경 + backendUrl = 'http://localhost:8080' + } + + // URL 유효성 검증 + if (!backendUrl || (!backendUrl.startsWith('http://') && !backendUrl.startsWith('https://'))) { + console.warn(`Invalid backend URL: ${backendUrl}, falling back to localhost`) + backendUrl = 'http://localhost:8080' + } + + console.log(`Backend URL configured: ${backendUrl}`) + return [ { - source: '/api/admin/:path*', - destination: 'http://localhost:8080/api/admin/:path*', + source: '/api/:path*', + destination: `${backendUrl}/api/:path*`, }, - ]; + ] }, - + // 정적 파일 최적화 images: { domains: [], formats: ['image/webp', 'image/avif'], }, - + // 환경 변수 설정 env: { CUSTOM_KEY: process.env.CUSTOM_KEY, }, - - // 빌드 최적화 - experimental: { - optimizePackageImports: ['antd'], - }, - + // Ant Design SSR 비활성화 transpilePackages: ['antd'], - + // TypeScript 설정 typescript: { // 빌드 시 타입 체크 무시 (CI/CD에서 별도 처리) ignoreBuildErrors: false, }, - + // ESLint 설정 eslint: { ignoreDuringBuilds: false, }, -}; -module.exports = nextConfig; + // Railway 환경에서 안정적인 빌드를 위한 설정 + output: 'standalone', + + // 정적 생성 최적화 (기존 experimental 설정과 병합) + experimental: { + optimizePackageImports: ['antd'], + // SSG 관련 설정 + isrMemoryCacheSize: 0, + }, +} + +module.exports = nextConfig diff --git a/weski-admin/package-lock.json b/weski-admin/package-lock.json index 02033ce..22340e2 100644 --- a/weski-admin/package-lock.json +++ b/weski-admin/package-lock.json @@ -22,6 +22,7 @@ "@types/react-dom": "^19.1.7", "eslint": "^9.33.0", "eslint-config-next": "^15.2.0", + "prettier": "^3.7.2", "typescript": "~5.8.3" } }, @@ -2051,9 +2052,9 @@ } }, "node_modules/axios": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", - "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -3910,9 +3911,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "dependencies": { "argparse": "^2.0.1" @@ -4489,6 +4490,21 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.2.tgz", + "integrity": "sha512-n3HV2J6QhItCXndGa3oMWvWFAgN1ibnS7R9mt6iokScBOC0Ul9/iZORmU2IWUMcyAQaMPjTlY3uT34TqocUxMA==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", diff --git a/weski-admin/package.json b/weski-admin/package.json index 6bc9f24..7d28abc 100644 --- a/weski-admin/package.json +++ b/weski-admin/package.json @@ -24,6 +24,7 @@ "@types/react-dom": "^19.1.7", "eslint": "^9.33.0", "eslint-config-next": "^15.2.0", + "prettier": "^3.7.2", "typescript": "~5.8.3" } -} \ No newline at end of file +} diff --git a/weski-admin/railway.json b/weski-admin/railway.json new file mode 100644 index 0000000..dc3f64d --- /dev/null +++ b/weski-admin/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { + "builder": "NIXPACKS" + }, + "deploy": { + "startCommand": "npm run start", + "healthcheckPath": "/", + "healthcheckTimeout": 100, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/weski-admin/src/api/skiResortApi.ts b/weski-admin/src/api/skiResortApi.ts index e6e8069..85cf5d9 100644 --- a/weski-admin/src/api/skiResortApi.ts +++ b/weski-admin/src/api/skiResortApi.ts @@ -1,15 +1,17 @@ -import axios from 'axios'; +import axios from 'axios' import type { ApiResponse, AdminSkiResortResponse, CreateSkiResortRequest, UpdateSkiResortRequest, -} from '@/types/skiResort'; + Slope, + UpdateSlopeRequest, + Webcam, + UpdateWebcamRequest, +} from '@/types/skiResort' // API 기본 설정 -const API_BASE_URL = process.env.NODE_ENV === 'production' - ? '/api/admin/ski-resorts' // 프로덕션에서는 Next.js rewrites 사용 - : 'http://localhost:8080/api/admin/ski-resorts'; // 개발 환경에서는 직접 연결 +const API_BASE_URL = '/api/admin/ski-resorts' const apiClient = axios.create({ baseURL: API_BASE_URL, @@ -17,83 +19,135 @@ const apiClient = axios.create({ headers: { 'Content-Type': 'application/json', }, -}); +}) // 요청/응답 인터셉터 apiClient.interceptors.request.use( (config) => { - console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`); - return config; + console.log(`API 요청: ${config.method?.toUpperCase()} ${config.url}`) + return config }, (error) => { - console.error('API 요청 에러:', error); - return Promise.reject(error); - } -); + console.error('API 요청 에러:', error) + return Promise.reject(error) + }, +) apiClient.interceptors.response.use( (response) => { - console.log(`API 응답: ${response.status} ${response.config.url}`); - return response; + console.log(`API 응답: ${response.status} ${response.config.url}`) + return response }, (error) => { - console.error('API 응답 에러:', error.response?.data || error.message); - return Promise.reject(error); - } -); + console.error('API 응답 에러:', error.response?.data || error.message) + return Promise.reject(error) + }, +) // 스키장 API 함수들 export const skiResortApi = { // 모든 스키장 조회 async getAllSkiResorts(): Promise { - const response = await apiClient.get>(''); - return response.data.data || []; + const response = await apiClient.get>('') + return response.data.data || [] }, // 특정 스키장 조회 async getSkiResort(resortId: number): Promise { - const response = await apiClient.get>(`/${resortId}`); + const response = await apiClient.get>(`/${resortId}`) if (!response.data.data) { - throw new Error('스키장 정보를 찾을 수 없습니다'); + throw new Error('스키장 정보를 찾을 수 없습니다') } - return response.data.data; + return response.data.data }, // 스키장 생성 async createSkiResort(data: CreateSkiResortRequest): Promise { - const response = await apiClient.post>('', data); + const response = await apiClient.post>('', data) if (!response.data.data) { - throw new Error('스키장 생성에 실패했습니다'); + throw new Error('스키장 생성에 실패했습니다') } - return response.data.data; + return response.data.data }, // 스키장 수정 async updateSkiResort( resortId: number, - data: UpdateSkiResortRequest + data: UpdateSkiResortRequest, ): Promise { - const response = await apiClient.put>(`/${resortId}`, data); + const response = await apiClient.put>(`/${resortId}`, data) if (!response.data.data) { - throw new Error('스키장 수정에 실패했습니다'); + throw new Error('스키장 수정에 실패했습니다') } - return response.data.data; + return response.data.data }, // 스키장 삭제 async deleteSkiResort(resortId: number): Promise { - await apiClient.delete(`/${resortId}`); + await apiClient.delete(`/${resortId}`) }, // 모든 스키장 상태 업데이트 async updateAllResortStatus(): Promise { - await apiClient.post('/batch/update-status'); + await apiClient.post('/batch/update-status') }, // 모든 스키장 슬로프 수 업데이트 async updateAllSlopeCount(): Promise { - await apiClient.post('/batch/update-slope-count'); + await apiClient.post('/batch/update-slope-count') + }, + + // 슬로프 수정 + async updateSlope(slopeId: number, data: UpdateSlopeRequest): Promise { + // /api/admin/slopes/{slopeId} 호출 + // apiClient의 baseURL이 /api/admin/ski-resorts 이므로 절대 경로로 호출하거나 baseURL을 재정의해야 함 + // 여기서는 axios 직접 호출 대신 apiClient를 사용하되 url을 덮어씀 + const response = await apiClient.put>(`/api/admin/slopes/${slopeId}`, data, { + baseURL: '', // baseURL 무시하고 절대 경로(현재 도메인 기준) 사용 + }) + + if (!response.data.data) { + throw new Error('슬로프 수정에 실패했습니다') + } + return response.data.data + }, + + // 슬로프 목록 조회 + async getSlopes(resortId: number): Promise { + // /api/slopes/{resortId} 호출 + const response = await apiClient.get(`/api/slopes/${resortId}`, { + baseURL: '', // baseURL 무시하고 절대 경로(현재 도메인 기준) 사용 + }) + // SlopeResponseDto 구조에 맞게 데이터 추출 + return response.data.slopes || [] + }, + + // 웹캠 목록 조회 + async getWebcams(resortId: number): Promise { + // /api/slopes/{resortId} 호출 (슬로프와 웹캠 정보를 함께 반환함) + const response = await apiClient.get(`/api/slopes/${resortId}`, { + baseURL: '', // baseURL 무시하고 절대 경로(현재 도메인 기준) 사용 + }) + // SlopeResponseDto 구조에 맞게 데이터 추출 + return response.data.webcams || [] + }, + + // 웹캠 수정 + async updateWebcam(webcamId: number, data: UpdateWebcamRequest): Promise { + // /api/admin/webcams/{webcamId} 호출 + const response = await apiClient.put>( + `/api/admin/webcams/${webcamId}`, + data, + { + baseURL: '', // baseURL 무시하고 절대 경로(현재 도메인 기준) 사용 + } + ) + + if (!response.data.data) { + throw new Error('웹캠 수정에 실패했습니다') + } + return response.data.data }, -}; +} -export default skiResortApi; +export default skiResortApi diff --git a/weski-admin/src/app/ski-resorts/[id]/page.tsx b/weski-admin/src/app/ski-resorts/[id]/page.tsx index cdc023c..e8b01a7 100644 --- a/weski-admin/src/app/ski-resorts/[id]/page.tsx +++ b/weski-admin/src/app/ski-resorts/[id]/page.tsx @@ -1,7 +1,7 @@ -'use client'; +'use client' -import React, { useState, useEffect } from 'react'; -import { useParams, useRouter } from 'next/navigation'; +import React, { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' import { Form, Input, @@ -17,76 +17,103 @@ import { Space, Spin, Alert, -} from 'antd'; -import { ArrowLeftOutlined, SaveOutlined, EditOutlined } from '@ant-design/icons'; -import dayjs from 'dayjs'; -import { skiResortApi } from '@/api/skiResortApi'; -import type { AdminSkiResortResponse, UpdateSkiResortRequest } from '@/types/skiResort'; + Table, + Tag, + Modal, + Switch, +} from 'antd' +import { + ArrowLeftOutlined, + SaveOutlined, + EditOutlined, + VideoCameraOutlined, +} from '@ant-design/icons' +import dayjs from 'dayjs' +import { skiResortApi } from '@/api/skiResortApi' +import type { + AdminSkiResortResponse, + UpdateSkiResortRequest, + Slope, + UpdateSlopeRequest, + Webcam, +} from '@/types/skiResort' -const { Title, Text } = Typography; -const { Option } = Select; +const { Title, Text } = Typography +const { Option } = Select export default function SkiResortDetailPage() { - const params = useParams(); - const router = useRouter(); - const [form] = Form.useForm(); - const [loading, setLoading] = useState(false); - const [saveLoading, setSaveLoading] = useState(false); - const [isEditing, setIsEditing] = useState(false); - const [skiResort, setSkiResort] = useState(null); + const params = useParams() + const router = useRouter() + const [form] = Form.useForm() + const [slopeForm] = Form.useForm() + const [loading, setLoading] = useState(false) + const [saveLoading, setSaveLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [skiResort, setSkiResort] = useState(null) + const [slopes, setSlopes] = useState([]) + const [webcams, setWebcams] = useState([]) + const [isSlopeModalVisible, setIsSlopeModalVisible] = useState(false) + const [editingSlope, setEditingSlope] = useState(null) - const id = params?.id as string; + const id = params?.id as string // 데이터 로드 - const loadData = async () => { - if (!id) return; - - setLoading(true); + const loadData = React.useCallback(async () => { + if (!id) return + + setLoading(true) try { - const data = await skiResortApi.getSkiResort(Number(id)); - setSkiResort(data); - + const [resortData, slopesData, webcamsData] = await Promise.all([ + skiResortApi.getSkiResort(Number(id)), + skiResortApi.getSlopes(Number(id)), + skiResortApi.getWebcams(Number(id)), + ]) + + setSkiResort(resortData) + setSlopes(slopesData) + setWebcams(webcamsData) + // 폼 데이터 설정 form.setFieldsValue({ - ...data, - openingDate: data.openingDate ? dayjs(data.openingDate) : null, - closingDate: data.closingDate ? dayjs(data.closingDate) : null, - }); + ...resortData, + openingDate: resortData.openingDate ? dayjs(resortData.openingDate) : null, + closingDate: resortData.closingDate ? dayjs(resortData.closingDate) : null, + }) } catch (error: any) { - console.error('데이터 로드 실패:', error); - message.error(error.response?.data?.message || '스키장 정보를 불러오는데 실패했습니다'); + console.error('데이터 로드 실패:', error) + message.error(error.response?.data?.message || '스키장 정보를 불러오는데 실패했습니다') } finally { - setLoading(false); + setLoading(false) } - }; + }, [id, form]) useEffect(() => { - loadData(); - }, [id]); + loadData() + }, [id, loadData]) // 폼 제출 처리 const handleSubmit = async (values: any) => { - if (!id) return; - - setSaveLoading(true); + if (!id) return + + setSaveLoading(true) try { const requestData: UpdateSkiResortRequest = { ...values, openingDate: values.openingDate?.format('YYYY-MM-DD'), closingDate: values.closingDate?.format('YYYY-MM-DD'), - }; + } - const updatedData = await skiResortApi.updateSkiResort(Number(id), requestData); - setSkiResort(updatedData); - setIsEditing(false); - message.success('스키장 정보가 성공적으로 수정되었습니다'); + const updatedData = await skiResortApi.updateSkiResort(Number(id), requestData) + setSkiResort(updatedData) + setIsEditing(false) + message.success('스키장 정보가 성공적으로 수정되었습니다') } catch (error: any) { - console.error('스키장 수정 실패:', error); - message.error(error.response?.data?.message || '스키장 수정에 실패했습니다'); + console.error('스키장 수정 실패:', error) + message.error(error.response?.data?.message || '스키장 수정에 실패했습니다') } finally { - setSaveLoading(false); + setSaveLoading(false) } - }; + } // 편집 취소 const handleCancelEdit = () => { @@ -95,17 +122,106 @@ export default function SkiResortDetailPage() { ...skiResort, openingDate: skiResort.openingDate ? dayjs(skiResort.openingDate) : null, closingDate: skiResort.closingDate ? dayjs(skiResort.closingDate) : null, - }); + }) + } + setIsEditing(false) + } + + // 슬로프 편집 모달 열기 + const showSlopeEditModal = (slope: Slope) => { + setEditingSlope(slope) + slopeForm.setFieldsValue({ + ...slope, + webcamNumber: slope.webcamNo, // webcamNo를 webcamNumber로 매핑 + }) + setIsSlopeModalVisible(true) + } + + // 슬로프 수정 제출 + const handleSlopeSubmit = async () => { + if (!editingSlope) return + + try { + const values = await slopeForm.validateFields() + const requestData: UpdateSlopeRequest = { + ...values, + } + + await skiResortApi.updateSlope(editingSlope.slopeId, requestData) + + message.success('슬로프 정보가 수정되었습니다') + setIsSlopeModalVisible(false) + + // 슬로프 목록 새로고침 + const updatedSlopes = await skiResortApi.getSlopes(Number(id)) + setSlopes(updatedSlopes) + } catch (error: any) { + console.error('슬로프 수정 실패:', error) + message.error('슬로프 수정에 실패했습니다') } - setIsEditing(false); - }; + } + + // 슬로프 테이블 컬럼 + const slopeColumns = [ + { + title: 'ID', + dataIndex: 'slopeId', + key: 'slopeId', + width: 80, + }, + { + title: '슬로프명', + dataIndex: 'name', + key: 'name', + }, + { + title: '난이도', + dataIndex: 'difficulty', + key: 'difficulty', + render: (difficulty: string) => { + let color = 'default' + if (difficulty === '초급') color = 'green' + if (difficulty === '중급') color = 'blue' + if (difficulty === '상급') color = 'red' + if (difficulty === '최상급') color = 'purple' + return {difficulty} + }, + }, + { + title: '주간', + dataIndex: 'isDayOperating', + key: 'isDayOperating', + render: (val: boolean) => (val ? 운영 : 미운영), + }, + { + title: '야간', + dataIndex: 'isNightOperating', + key: 'isNightOperating', + render: (val: boolean) => (val ? 운영 : 미운영), + }, + { + title: '심야', + dataIndex: 'isLateNightOperating', + key: 'isLateNightOperating', + render: (val: boolean) => (val ? 운영 : 미운영), + }, + { + title: '작업', + key: 'action', + render: (_: any, record: Slope) => ( + + ), + }, + ] if (loading) { return (
- ); + ) } if (!skiResort) { @@ -116,14 +232,10 @@ export default function SkiResortDetailPage() { description="요청하신 스키장 정보가 존재하지 않습니다." type="error" showIcon - action={ - - } + action={} /> - ); + ) } return ( @@ -132,10 +244,7 @@ export default function SkiResortDetailPage() {
관리자 - router.push('/ski-resorts')} - className="cursor-pointer" - > + router.push('/ski-resorts')} className="cursor-pointer"> 스키장 관리 {skiResort.name} @@ -145,11 +254,7 @@ export default function SkiResortDetailPage() { {skiResort.name} 상세 정보 {!isEditing && ( - )} @@ -170,11 +275,11 @@ export default function SkiResortDetailPage() { {/* 기본 정보 */} 기본 정보 - + {skiResort.resortId} - + 운영 시간 - + @@ -264,7 +369,7 @@ export default function SkiResortDetailPage() { {/* 위치 정보 */} 위치 정보 - + 메타데이터 - + {new Date(skiResort.createdAt).toLocaleString('ko-KR')} @@ -317,7 +422,9 @@ export default function SkiResortDetailPage() { {isEditing && ( -

수정 안내:

+

+ 수정 안내: +

  • 슬로프 수는 자동으로 계산됩니다
  • 운영 상태는 날짜 기준으로 자동 업데이트 가능합니다
  • @@ -331,17 +438,12 @@ export default function SkiResortDetailPage() { {/* 폼 액션 버튼 */}
    - {isEditing ? ( <> - + )} @@ -364,6 +462,159 @@ export default function SkiResortDetailPage() {
    + + {/* 슬로프 목록 섹션 */} + +
    + + 슬로프 목록 + +
    + + + + + {/* 웹캠 목록 섹션 */} + +
    + + <VideoCameraOutlined /> 웹캠 목록 + + +
    + +
    + url ? ( + + {url} + + ) : ( + - + ), + }, + ]} + dataSource={webcams} + rowKey="id" + pagination={false} + size="small" + /> + + + {/* 슬로프 수정 모달 */} + setIsSlopeModalVisible(false)} + okText="저장" + cancelText="취소" + > +
    + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + - ); + ) } diff --git a/weski-admin/src/app/ski-resorts/[id]/webcams/page.tsx b/weski-admin/src/app/ski-resorts/[id]/webcams/page.tsx new file mode 100644 index 0000000..a5cb09b --- /dev/null +++ b/weski-admin/src/app/ski-resorts/[id]/webcams/page.tsx @@ -0,0 +1,186 @@ +'use client' + +import React, { useState, useEffect, useCallback } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { + Table, + Button, + Modal, + Form, + Input, + message, + Breadcrumb, + Typography, + Card, + Spin, +} from 'antd' +import { ArrowLeftOutlined, EditOutlined, VideoCameraOutlined } from '@ant-design/icons' +import { skiResortApi } from '@/api/skiResortApi' +import type { Webcam } from '@/types/skiResort' + +const { Title } = Typography + +export default function WebcamListPage() { + const params = useParams() + const router = useRouter() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [webcams, setWebcams] = useState([]) + const [isModalVisible, setIsModalVisible] = useState(false) + const [editingWebcam, setEditingWebcam] = useState(null) + const [resortName, setResortName] = useState('') + + const id = params?.id as string + + const loadData = useCallback(async () => { + if (!id) return + setLoading(true) + try { + // 스키장 정보도 가져와서 이름 표시 + const [resortData, webcamsData] = await Promise.all([ + skiResortApi.getSkiResort(Number(id)), + skiResortApi.getWebcams(Number(id)), + ]) + setResortName(resortData.name) + setWebcams(webcamsData) + } catch (error) { + console.error('데이터 로드 실패:', error) + message.error('데이터를 불러오는데 실패했습니다') + } finally { + setLoading(false) + } + }, [id]) + + useEffect(() => { + loadData() + }, [loadData]) + + const showEditModal = (webcam: Webcam) => { + setEditingWebcam(webcam) + form.setFieldsValue({ + name: webcam.name, + description: webcam.description, + url: webcam.url, + }) + setIsModalVisible(true) + } + + const handleUpdate = async () => { + if (!editingWebcam) return + try { + const values = await form.validateFields() + await skiResortApi.updateWebcam(editingWebcam.id, values) + message.success('웹캠 정보가 수정되었습니다') + setIsModalVisible(false) + form.resetFields() + loadData() // 목록 새로고침 + } catch (error) { + console.error('웹캠 수정 실패:', error) + message.error('웹캠 수정에 실패했습니다') + } + } + + const columns = [ + { title: 'ID', dataIndex: 'id', key: 'id', width: 80 }, + { title: '번호', dataIndex: 'number', key: 'number', width: 80 }, + { title: '이름', dataIndex: 'name', key: 'name' }, + { title: '설명', dataIndex: 'description', key: 'description' }, + { + title: 'URL', + dataIndex: 'url', + key: 'url', + render: (url?: string) => url ? ( + + {url} + + ) : - + }, + { + title: '작업', + key: 'action', + render: (_: any, record: Webcam) => ( + + ), + }, + ] + + return ( +
    +
    + + 관리자 + router.push('/ski-resorts')} className="cursor-pointer"> + 스키장 관리 + + router.push(`/ski-resorts/${id}`)} className="cursor-pointer"> + {resortName} + + 웹캠 관리 + + + <VideoCameraOutlined /> {resortName} 웹캠 관리 + +
    + + +
    + +
    + + {loading ? ( +
    + +
    + ) : ( +
    + )} + + + { + setIsModalVisible(false) + form.resetFields() + }} + okText="저장" + cancelText="취소" + > +
    + + + + + + + + + +
    + * 모든 필드는 선택사항입니다. 변경하지 않을 필드는 비워두세요.
    + * URL을 제거하려면 필드를 비워두고 저장하세요. +
    + +
    + + ) +} diff --git a/weski-admin/src/types/skiResort.ts b/weski-admin/src/types/skiResort.ts index 0f61fe4..cf353d6 100644 --- a/weski-admin/src/types/skiResort.ts +++ b/weski-admin/src/types/skiResort.ts @@ -1,68 +1,110 @@ // API 응답 타입 export interface ApiResponse { - success: boolean; - message: string; - data?: T; + success: boolean + message: string + data?: T } // 스키장 상태 열거형 -export type ResortStatus = '운영중' | '운영종료' | '예정'; +export type ResortStatus = '운영중' | '운영종료' | '예정' // Admin용 스키장 응답 DTO export interface AdminSkiResortResponse { - resortId: number; - name: string; - status: string; - openingDate?: string; - closingDate?: string; - openSlopes: number; - totalSlopes: number; - dayOperatingHours?: string; - nightOperatingHours?: string; - lateNightOperatingHours?: string; - dawnOperatingHours?: string; - midnightOperatingHours?: string; - snowfallTime?: string; - xCoordinate: string; - yCoordinate: string; - detailedAreaCode: string; - broadAreaCode: string; - createdAt: string; - updatedAt: string; + resortId: number + name: string + status: string + openingDate?: string + closingDate?: string + openSlopes: number + totalSlopes: number + dayOperatingHours?: string + nightOperatingHours?: string + lateNightOperatingHours?: string + dawnOperatingHours?: string + midnightOperatingHours?: string + snowfallTime?: string + xCoordinate: string + yCoordinate: string + detailedAreaCode: string + broadAreaCode: string + createdAt: string + updatedAt: string } // 스키장 생성 요청 DTO export interface CreateSkiResortRequest { - name: string; - status: ResortStatus; - openingDate?: string; - closingDate?: string; - dayOperatingHours?: string; - nightOperatingHours?: string; - lateNightOperatingHours?: string; - dawnOperatingHours?: string; - midnightOperatingHours?: string; - snowfallTime?: string; - xCoordinate: string; - yCoordinate: string; - detailedAreaCode: string; - broadAreaCode: string; + name: string + status: ResortStatus + openingDate?: string + closingDate?: string + dayOperatingHours?: string + nightOperatingHours?: string + lateNightOperatingHours?: string + dawnOperatingHours?: string + midnightOperatingHours?: string + snowfallTime?: string + xCoordinate: string + yCoordinate: string + detailedAreaCode: string + broadAreaCode: string } // 스키장 수정 요청 DTO export interface UpdateSkiResortRequest { - name?: string; - status?: ResortStatus; - openingDate?: string; - closingDate?: string; - dayOperatingHours?: string; - nightOperatingHours?: string; - lateNightOperatingHours?: string; - dawnOperatingHours?: string; - midnightOperatingHours?: string; - snowfallTime?: string; - xCoordinate?: string; - yCoordinate?: string; - detailedAreaCode?: string; - broadAreaCode?: string; + name?: string + status?: ResortStatus + openingDate?: string + closingDate?: string + dayOperatingHours?: string + nightOperatingHours?: string + lateNightOperatingHours?: string + dawnOperatingHours?: string + midnightOperatingHours?: string + snowfallTime?: string + xCoordinate?: string + yCoordinate?: string + detailedAreaCode?: string + broadAreaCode?: string +} + +// 슬로프 타입 +export interface Slope { + slopeId: number + name: string + difficulty: string + isDayOperating: boolean + isNightOperating: boolean + isLateNightOperating: boolean + isDawnOperating: boolean + isMidnightOperating: boolean + webcamNo?: number +} + +// 슬로프 수정 요청 DTO +export interface UpdateSlopeRequest { + name: string + difficulty: string + webcamNumber?: number + isDayOperating: boolean + isNightOperating: boolean + isLateNightOperating: boolean + isDawnOperating: boolean + isMidnightOperating: boolean +} + +// 웹캠 타입 +export interface Webcam { + id: number + name: string + number: number + description?: string + url?: string + isExternal?: boolean +} + +// 웹캠 수정 요청 DTO +export interface UpdateWebcamRequest { + name?: string + description?: string + url?: string }