diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index 77fb19d..912190c 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -67,8 +67,8 @@ jobs: - name: Verify production migration 004 used market schema working-directory: ${{ github.workspace }} run: | - chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-004.sh - scripts/verify-production-migration-004.sh + chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-005.sh + scripts/verify-production-migration-005.sh - run: ./gradlew spotlessCheck - run: ./gradlew test build -x spotlessCheck diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 8435c8d..302e7b6 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -103,8 +103,8 @@ jobs: - name: Verify production migration 004 used market schema working-directory: ${{ github.workspace }} run: | - chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-004.sh - scripts/verify-production-migration-004.sh + chmod +x scripts/run-production-migrations.sh scripts/verify-production-migration-005.sh + scripts/verify-production-migration-005.sh - name: Build and Test with Gradle run: ./gradlew test build -x spotlessCheck diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java index 3027cf8..daa74ac 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/entity/Resort.java @@ -24,9 +24,27 @@ public class Resort { @Column(name = "region", nullable = false, length = 50) private String regionName; + @Column(name = "code", nullable = false, length = 30, unique = true) + private String code; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_active", nullable = false) + private boolean active; + @Builder - public Resort(String name, String regionName) { + public Resort(String name, String regionName, String code, int displayOrder, boolean active) { this.name = name; this.regionName = regionName; + this.code = code; + this.displayOrder = displayOrder; + this.active = active; + } + + public void updateMetadata(String code, int displayOrder, boolean active) { + this.code = code; + this.displayOrder = displayOrder; + this.active = active; } } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java index a90c81b..e16fd68 100644 --- a/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java +++ b/backend/src/main/java/com/ikae/snowthing/domain/member/repository/ResortRepository.java @@ -1,5 +1,6 @@ package com.ikae.snowthing.domain.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,4 +9,8 @@ public interface ResortRepository extends JpaRepository { Optional findByName(String name); + + Optional findByCode(String code); + + List findAllByActiveTrueOrderByDisplayOrderAscIdAsc(); } diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/controller/ResortCameraController.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/controller/ResortCameraController.java new file mode 100644 index 0000000..9f183ca --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/controller/ResortCameraController.java @@ -0,0 +1,24 @@ +package com.ikae.snowthing.domain.resortcam.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraListResponse; +import com.ikae.snowthing.domain.resortcam.service.ResortCameraQueryService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/v1/resort-cams") +@RequiredArgsConstructor +public class ResortCameraController { + + private final ResortCameraQueryService resortCameraQueryService; + + @GetMapping + public ResponseEntity getResortCameras() { + return ResponseEntity.ok(resortCameraQueryService.getActiveResortCameras()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponse.java new file mode 100644 index 0000000..d817e31 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponse.java @@ -0,0 +1,15 @@ +package com.ikae.snowthing.domain.resortcam.dto; + +import java.util.List; + +public record ResortCameraGroupResponse( + String code, + String name, + String regionName, + int displayOrder, + List cameras) { + + public ResortCameraGroupResponse { + cameras = List.copyOf(cameras); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraItemResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraItemResponse.java new file mode 100644 index 0000000..3b32bd1 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraItemResponse.java @@ -0,0 +1,23 @@ +package com.ikae.snowthing.domain.resortcam.dto; + +import com.ikae.snowthing.domain.resortcam.entity.CameraSourceType; +import com.ikae.snowthing.domain.resortcam.entity.ResortCamera; + +public record ResortCameraItemResponse( + String code, + String name, + CameraSourceType sourceType, + String sourceUrl, + String externalPageUrl, + int displayOrder) { + + public static ResortCameraItemResponse from(ResortCamera camera) { + return new ResortCameraItemResponse( + camera.getCode(), + camera.getName(), + camera.getSourceType(), + camera.getSourceUrl(), + camera.getExternalPageUrl(), + camera.getDisplayOrder()); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraListResponse.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraListResponse.java new file mode 100644 index 0000000..8c4633d --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraListResponse.java @@ -0,0 +1,10 @@ +package com.ikae.snowthing.domain.resortcam.dto; + +import java.util.List; + +public record ResortCameraListResponse(List resorts) { + + public ResortCameraListResponse { + resorts = List.copyOf(resorts); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/CameraSourceType.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/CameraSourceType.java new file mode 100644 index 0000000..34ef1fd --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/CameraSourceType.java @@ -0,0 +1,8 @@ +package com.ikae.snowthing.domain.resortcam.entity; + +public enum CameraSourceType { + HLS, + YOUTUBE, + IFRAME, + EXTERNAL_LINK +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/ResortCamera.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/ResortCamera.java new file mode 100644 index 0000000..24d4509 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/entity/ResortCamera.java @@ -0,0 +1,78 @@ +package com.ikae.snowthing.domain.resortcam.entity; + +import jakarta.persistence.*; + +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.global.common.BaseTimeEntity; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "resort_camera", + uniqueConstraints = + @UniqueConstraint( + name = "uk_resort_camera_resort_code", + columnNames = {"resort_id", "code"}), + indexes = + @Index( + name = "idx_resort_camera_active_order", + columnList = "resort_id,is_active,display_order,resort_camera_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ResortCamera extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "resort_camera_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "resort_id", nullable = false) + private Resort resort; + + @Column(name = "code", nullable = false, length = 50) + private String code; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 20) + private CameraSourceType sourceType; + + @Column(name = "source_url", nullable = false, length = 1000) + private String sourceUrl; + + @Column(name = "external_page_url", length = 1000) + private String externalPageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_active", nullable = false) + private boolean active; + + @Builder + public ResortCamera( + Resort resort, + String code, + String name, + CameraSourceType sourceType, + String sourceUrl, + String externalPageUrl, + int displayOrder, + boolean active) { + this.resort = resort; + this.code = code; + this.name = name; + this.sourceType = sourceType; + this.sourceUrl = sourceUrl; + this.externalPageUrl = externalPageUrl; + this.displayOrder = displayOrder; + this.active = active; + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/repository/ResortCameraRepository.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/repository/ResortCameraRepository.java new file mode 100644 index 0000000..d5b8895 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/repository/ResortCameraRepository.java @@ -0,0 +1,22 @@ +package com.ikae.snowthing.domain.resortcam.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import com.ikae.snowthing.domain.resortcam.entity.ResortCamera; + +public interface ResortCameraRepository extends JpaRepository { + + @Query( + """ + select camera + from ResortCamera camera + join fetch camera.resort resort + where camera.active = true and resort.active = true + order by resort.displayOrder asc, resort.id asc, + camera.displayOrder asc, camera.id asc + """) + List findAllActiveOrdered(); +} diff --git a/backend/src/main/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryService.java b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryService.java new file mode 100644 index 0000000..88bc529 --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryService.java @@ -0,0 +1,54 @@ +package com.ikae.snowthing.domain.resortcam.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraGroupResponse; +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraItemResponse; +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraListResponse; +import com.ikae.snowthing.domain.resortcam.repository.ResortCameraRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ResortCameraQueryService { + + private final ResortRepository resortRepository; + private final ResortCameraRepository resortCameraRepository; + + public ResortCameraListResponse getActiveResortCameras() { + Map> camerasByResort = + resortCameraRepository.findAllActiveOrdered().stream() + .collect( + Collectors.groupingBy( + camera -> camera.getResort().getCode(), + Collectors.mapping( + ResortCameraItemResponse::from, + Collectors.toList()))); + + List resorts = + resortRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc().stream() + .filter(resort -> camerasByResort.containsKey(resort.getCode())) + .map(resort -> toResponse(resort, camerasByResort)) + .toList(); + return new ResortCameraListResponse(resorts); + } + + private ResortCameraGroupResponse toResponse( + Resort resort, Map> camerasByResort) { + return new ResortCameraGroupResponse( + resort.getCode(), + resort.getName(), + resort.getRegionName(), + resort.getDisplayOrder(), + camerasByResort.getOrDefault(resort.getCode(), List.of())); + } +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java index cdf4a1e..7ab40e2 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/DataInitializer.java @@ -1,9 +1,11 @@ package com.ikae.snowthing.global.config; import java.util.List; +import java.util.Optional; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; @@ -25,6 +27,7 @@ @Component @Profile("local") +@Order(1) @RequiredArgsConstructor public class DataInitializer implements CommandLineRunner { @@ -60,15 +63,36 @@ public void run(String... args) { .build()); } - if (resortRepository.count() == 0) { - resortRepository.saveAll( - List.of( - Resort.builder().name("휘닉스파크").regionName("강원 평창").build(), - Resort.builder().name("하이원리조트").regionName("강원 정선").build(), - Resort.builder().name("모나용평").regionName("강원 평창").build(), - Resort.builder().name("비발디파크").regionName("강원 홍천").build(), - Resort.builder().name("웰리힐리파크").regionName("강원 횡성").build(), - Resort.builder().name("지산리조트").regionName("경기 이천").build())); + List targetResorts = + List.of( + resort("PHOENIX", "휘닉스파크", "강원 평창", 1), + resort("VIVALDI", "비발디파크", "강원 홍천", 2), + resort("HIGH1", "하이원리조트", "강원 정선", 3), + resort("YONGPYONG", "모나용평", "강원 평창", 4), + resort("WELLI_HILLI", "웰리힐리파크", "강원 횡성", 5), + resort("JISAN", "지산리조트", "경기 이천", 6), + resort("KONJIAM", "곤지암리조트", "경기 광주", 7), + resort("MUJU", "무주덕유산리조트", "전북 무주", 8), + resort("EDEN_VALLEY", "에덴밸리리조트", "경남 양산", 9), + resort("ELYSIAN", "엘리시안 강촌", "강원 춘천", 10), + resort("ALPENSIA", "알펜시아리조트", "강원 평창", 11), + resort("OAK_VALLEY", "오크밸리", "강원 원주", 12), + resort("O2_RESORT", "오투리조트", "강원 태백", 13)); + + for (Resort target : targetResorts) { + Optional byCode = resortRepository.findByCode(target.getCode()); + if (byCode.isPresent()) { + continue; + } + Optional byName = resortRepository.findByName(target.getName()); + if (byName.isPresent()) { + Resort existing = byName.get(); + existing.updateMetadata( + target.getCode(), target.getDisplayOrder(), target.isActive()); + resortRepository.save(existing); + } else { + resortRepository.save(target); + } } if (ridingStyleRepository.count() == 0) { @@ -172,4 +196,14 @@ private MarketCategory marketCategory(String code, String name, int sortOrder) { .active(true) .build(); } + + private Resort resort(String code, String name, String regionName, int displayOrder) { + return Resort.builder() + .code(code) + .name(name) + .regionName(regionName) + .displayOrder(displayOrder) + .active(true) + .build(); + } } diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/ResortCameraDataInitializer.java b/backend/src/main/java/com/ikae/snowthing/global/config/ResortCameraDataInitializer.java new file mode 100644 index 0000000..7c1b4ff --- /dev/null +++ b/backend/src/main/java/com/ikae/snowthing/global/config/ResortCameraDataInitializer.java @@ -0,0 +1,74 @@ +package com.ikae.snowthing.global.config; + +import java.io.InputStream; +import java.util.List; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortcam.entity.CameraSourceType; +import com.ikae.snowthing.domain.resortcam.entity.ResortCamera; +import com.ikae.snowthing.domain.resortcam.repository.ResortCameraRepository; +import com.ikae.snowthing.global.error.ErrorCode; +import com.ikae.snowthing.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@Profile("local") +@Order(2) +@RequiredArgsConstructor +public class ResortCameraDataInitializer implements CommandLineRunner { + + private final ResortRepository resortRepository; + private final ResortCameraRepository resortCameraRepository; + private final ObjectMapper objectMapper; + + @Override + public void run(String... args) throws Exception { + if (resortCameraRepository.count() > 0) { + return; + } + + ClassPathResource resource = new ClassPathResource("resort-cameras-local.json"); + try (InputStream inputStream = resource.getInputStream()) { + List seeds = + objectMapper.readValue(inputStream, new TypeReference>() {}); + List cameras = seeds.stream().map(this::toEntity).toList(); + resortCameraRepository.saveAll(cameras); + } + } + + private ResortCamera toEntity(CameraSeed seed) { + Resort resort = + resortRepository + .findByCode(seed.resortCode()) + .orElseThrow(() -> new CustomException(ErrorCode.INTERNAL_SERVER_ERROR)); + return ResortCamera.builder() + .resort(resort) + .code(seed.code()) + .name(seed.name()) + .sourceType(seed.sourceType()) + .sourceUrl(seed.sourceUrl()) + .externalPageUrl(seed.externalPageUrl()) + .displayOrder(seed.displayOrder()) + .active(true) + .build(); + } + + private record CameraSeed( + String resortCode, + String code, + String name, + CameraSourceType sourceType, + String sourceUrl, + String externalPageUrl, + int displayOrder) {} +} diff --git a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java index df75e46..c7634c7 100644 --- a/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java +++ b/backend/src/main/java/com/ikae/snowthing/global/config/SecurityConfig.java @@ -170,6 +170,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/master/resorts", "/api/riding-styles", "/api/v1/master/riding-styles", + "/api/v1/resort-cams", "/api/posts", "/api/posts/**", "/api/v1/posts", diff --git a/backend/src/main/resources/resort-cameras-local.json b/backend/src/main/resources/resort-cameras-local.json new file mode 100644 index 0000000..de80088 --- /dev/null +++ b/backend/src/main/resources/resort-cameras-local.json @@ -0,0 +1,785 @@ +[ + { + "resortCode": "KONJIAM", + "code": "CAM_01", + "name": "정상 휴게소", + "sourceType": "HLS", + "sourceUrl": "https://video.fpkorea.com/konjiam/cam01.stream/playlist.m3u8", + "externalPageUrl": "https://m.konjiamresort.co.kr/ski/skiLveCam.dev", + "displayOrder": 1 + }, + { + "resortCode": "KONJIAM", + "code": "CAM_02", + "name": "정상부 슬로프 (씽큐, 그램)", + "sourceType": "HLS", + "sourceUrl": "https://video.fpkorea.com/konjiam/cam02.stream/playlist.m3u8", + "externalPageUrl": "https://m.konjiamresort.co.kr/ski/skiLveCam.dev", + "displayOrder": 2 + }, + { + "resortCode": "KONJIAM", + "code": "CAM_03", + "name": "초중급 베이스 (휘센)", + "sourceType": "HLS", + "sourceUrl": "https://video.fpkorea.com/konjiam/cam03.stream/playlist.m3u8", + "externalPageUrl": "https://m.konjiamresort.co.kr/ski/skiLveCam.dev", + "displayOrder": 3 + }, + { + "resortCode": "KONJIAM", + "code": "CAM_04", + "name": "중상급 베이스 (와이낫, CNP)", + "sourceType": "HLS", + "sourceUrl": "https://video.fpkorea.com/konjiam/cam04.stream/playlist.m3u8", + "externalPageUrl": "https://m.konjiamresort.co.kr/ski/skiLveCam.dev", + "displayOrder": 4 + }, + { + "resortCode": "KONJIAM", + "code": "CAM_05", + "name": "중간 슬로프 (와이낫 상단)", + "sourceType": "HLS", + "sourceUrl": "https://video.fpkorea.com/konjiam/cam05.stream/playlist.m3u8", + "externalPageUrl": "https://m.konjiamresort.co.kr/ski/skiLveCam.dev", + "displayOrder": 5 + }, + { + "resortCode": "JISAN", + "code": "CAM_01", + "name": "레몬 탑승장", + "sourceType": "HLS", + "sourceUrl": "https://ant.livecity.co.kr:5443/jisancam/streams/jisan1.m3u8", + "externalPageUrl": "https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp", + "displayOrder": 1 + }, + { + "resortCode": "JISAN", + "code": "CAM_02", + "name": "오렌지 / 뉴오렌지 탑승장", + "sourceType": "HLS", + "sourceUrl": "https://ant.livecity.co.kr:5443/jisancam/streams/jisan2.m3u8", + "externalPageUrl": "https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp", + "displayOrder": 2 + }, + { + "resortCode": "JISAN", + "code": "CAM_03", + "name": "5번 / 6번 슬로프", + "sourceType": "HLS", + "sourceUrl": "https://ant.livecity.co.kr:5443/jisancam/streams/jisan3.m3u8", + "externalPageUrl": "https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp", + "displayOrder": 3 + }, + { + "resortCode": "JISAN", + "code": "CAM_04", + "name": "블루 탑승장", + "sourceType": "HLS", + "sourceUrl": "https://ant.livecity.co.kr:5443/jisancam/streams/jisan4.m3u8", + "externalPageUrl": "https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp", + "displayOrder": 4 + }, + { + "resortCode": "JISAN", + "code": "CAM_05", + "name": "실버 탑승장", + "sourceType": "HLS", + "sourceUrl": "https://ant.livecity.co.kr:5443/jisancam/streams/jisan5.m3u8", + "externalPageUrl": "https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp", + "displayOrder": 5 + }, + { + "resortCode": "ELYSIAN", + "code": "CAM_01", + "name": "실시간 영상", + "sourceType": "YOUTUBE", + "sourceUrl": "https://www.youtube.com/embed/O6_8VBu4FSA", + "externalPageUrl": "https://www.youtube.com/@11-lf8zw/streams", + "displayOrder": 1 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_01", + "name": "슬로프 전경", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=8&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 1 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_02", + "name": "발라드 상단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=11&serial=TD0314A17496&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 2 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_03", + "name": "발라드 하단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=2&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 3 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_04", + "name": "재즈 상단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=4&serial=XU0121A37907&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 4 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_05", + "name": "재즈 하단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=1&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 5 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_06", + "name": "테크노 상단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=2&serial=XU0121A37904&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 6 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_07", + "name": "테크노 하단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=5&serial=XU0121A37904&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 7 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_08", + "name": "블루스", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=4&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 8 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_09", + "name": "클래식", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=10&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 9 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_10", + "name": "레게", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=10&serial=TD0314A17496&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 10 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_11", + "name": "펑키 상단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=5&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 11 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_12", + "name": "펑키 하단", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=8&serial=XU0121A37904&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 12 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_13", + "name": "힙합", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=6&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 13 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_14", + "name": "스키월드 정상", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=9&serial=TW0014A15451&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 14 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_15", + "name": "스노위랜드1", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=16&serial=TD0314A17496&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 15 + }, + { + "resortCode": "VIVALDI", + "code": "CAM_16", + "name": "스노위랜드2", + "sourceType": "IFRAME", + "sourceUrl": "/vivaldi.html?channel=12&serial=TD0314A17496&autoplay=true", + "externalPageUrl": "https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S", + "displayOrder": 16 + }, + { + "resortCode": "OAK_VALLEY", + "code": "CAM_01", + "name": "베이스", + "sourceType": "HLS", + "sourceUrl": "https://cctv-oak9.ktcdn.co.kr/cctv/ch2.stream/chunklist.m3u8", + "externalPageUrl": "https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do", + "displayOrder": 1 + }, + { + "resortCode": "OAK_VALLEY", + "code": "CAM_02", + "name": "I 슬로프", + "sourceType": "HLS", + "sourceUrl": "https://cctv-oak9.ktcdn.co.kr/cctv/ch9.stream/chunklist.m3u8", + "externalPageUrl": "https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do", + "displayOrder": 2 + }, + { + "resortCode": "OAK_VALLEY", + "code": "CAM_03", + "name": "G 슬로프 하단", + "sourceType": "HLS", + "sourceUrl": "https://cctv-oak9.ktcdn.co.kr/cctv/ch7.stream/chunklist.m3u8", + "externalPageUrl": "https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do", + "displayOrder": 3 + }, + { + "resortCode": "OAK_VALLEY", + "code": "CAM_04", + "name": "F 슬로프 상단", + "sourceType": "HLS", + "sourceUrl": "https://cctv-oak9.ktcdn.co.kr/cctv/ch6.stream/chunklist.m3u8", + "externalPageUrl": "https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do", + "displayOrder": 4 + }, + { + "resortCode": "OAK_VALLEY", + "code": "CAM_05", + "name": "플라워 휴게소", + "sourceType": "HLS", + "sourceUrl": "https://cctv-oak9.ktcdn.co.kr/cctv/ch5.stream/chunklist.m3u8", + "externalPageUrl": "https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do", + "displayOrder": 5 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_01", + "name": "알파", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam02.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 1 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_02", + "name": "베이스", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam03.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 2 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_03", + "name": "슬로프 광장 / 전경", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam04.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 3 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_04", + "name": "정상 광장", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam05.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 4 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_05", + "name": "슬로프 전경", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam06.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 5 + }, + { + "resortCode": "WELLI_HILLI", + "code": "CAM_06", + "name": "워터플래닛", + "sourceType": "HLS", + "sourceUrl": "https://live.wellihillipark.com/wellihillipark/_definst_/cam07.stream/playlist.m3u8", + "externalPageUrl": "https://m.wellihillipark.com/customer/webcam", + "displayOrder": 6 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_01", + "name": "호크 / 스패로우", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/yh_02.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 1 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_02", + "name": "도도", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/sp_01.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 2 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_03", + "name": "불새마루", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/ht_01.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 3 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_04", + "name": "베이스", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/bc_02.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 4 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_05", + "name": "펭귄", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/bc_01.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 5 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_06", + "name": "스노우 빌리지", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/yh_01.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 6 + }, + { + "resortCode": "PHOENIX", + "code": "CAM_07", + "name": "몽블랑 정상", + "sourceType": "HLS", + "sourceUrl": "https://streaming.phoenixhnr.co.kr/hls/mb_03.m3u8", + "externalPageUrl": "https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie", + "displayOrder": 7 + }, + { + "resortCode": "ALPENSIA", + "code": "CAM_01", + "name": "알펜시아 리조트 라이브캠", + "sourceType": "YOUTUBE", + "sourceUrl": "https://www.youtube.com/embed/I3azd8f2HhM", + "externalPageUrl": "https://www.alpensia.com/guide/web-cam.do", + "displayOrder": 1 + }, + { + "resortCode": "YONGPYONG", + "code": "CAM_01", + "name": "발왕산 氣 스카이워크", + "sourceType": "HLS", + "sourceUrl": "https://live.yongpyong.co.kr/cam01/index.m3u8", + "externalPageUrl": "https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do", + "displayOrder": 1 + }, + { + "resortCode": "YONGPYONG", + "code": "CAM_02", + "name": "발왕산 천년주목숲길", + "sourceType": "HLS", + "sourceUrl": "https://live.yongpyong.co.kr/cam02/index.m3u8", + "externalPageUrl": "https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do", + "displayOrder": 2 + }, + { + "resortCode": "YONGPYONG", + "code": "CAM_03", + "name": "옐로우 슬로프", + "sourceType": "HLS", + "sourceUrl": "https://live.yongpyong.co.kr/cam11/index.m3u8", + "externalPageUrl": "https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do", + "displayOrder": 3 + }, + { + "resortCode": "YONGPYONG", + "code": "CAM_04", + "name": "베이스 전경 / 레드 슬로프", + "sourceType": "HLS", + "sourceUrl": "https://live.yongpyong.co.kr/cam08/index.m3u8", + "externalPageUrl": "https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do", + "displayOrder": 4 + }, + { + "resortCode": "YONGPYONG", + "code": "CAM_05", + "name": "모나용평 진입로", + "sourceType": "HLS", + "sourceUrl": "https://live.yongpyong.co.kr/cam05/index.m3u8", + "externalPageUrl": "https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do", + "displayOrder": 5 + }, + { + "resortCode": "HIGH1", + "code": "CAM_01", + "name": "하이원탑 (제우스1 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/1/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=1", + "displayOrder": 1 + }, + { + "resortCode": "HIGH1", + "code": "CAM_02", + "name": "하이원탑 (헤라2 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/2/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=2", + "displayOrder": 2 + }, + { + "resortCode": "HIGH1", + "code": "CAM_03", + "name": "마운틴허브 (스노우월드 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/4/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=4", + "displayOrder": 3 + }, + { + "resortCode": "HIGH1", + "code": "CAM_04", + "name": "마운틴허브 (아폴로3 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/5/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=5", + "displayOrder": 4 + }, + { + "resortCode": "HIGH1", + "code": "CAM_05", + "name": "마운틴허브 (아테나2 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/6/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=6", + "displayOrder": 5 + }, + { + "resortCode": "HIGH1", + "code": "CAM_06", + "name": "마운틴 베이스 (아테나 리프트)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/7/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=7", + "displayOrder": 6 + }, + { + "resortCode": "HIGH1", + "code": "CAM_07", + "name": "아테나2 하단", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/8/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=8", + "displayOrder": 7 + }, + { + "resortCode": "HIGH1", + "code": "CAM_08", + "name": "밸리허브 (헤라 리프트 입구)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/11/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=11", + "displayOrder": 8 + }, + { + "resortCode": "HIGH1", + "code": "CAM_09", + "name": "밸리허브 (아폴로3 합류)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/12/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=12", + "displayOrder": 9 + }, + { + "resortCode": "HIGH1", + "code": "CAM_10", + "name": "밸리허브 (제우스3)", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/13/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=13", + "displayOrder": 10 + }, + { + "resortCode": "HIGH1", + "code": "CAM_11", + "name": "제우스3 중단", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/14/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=14", + "displayOrder": 11 + }, + { + "resortCode": "HIGH1", + "code": "CAM_12", + "name": "아폴로4 중단", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/15/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=15", + "displayOrder": 12 + }, + { + "resortCode": "HIGH1", + "code": "CAM_13", + "name": "아폴로 베이스", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/16/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=16", + "displayOrder": 13 + }, + { + "resortCode": "HIGH1", + "code": "CAM_14", + "name": "제우스3 하단", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/17/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=17", + "displayOrder": 14 + }, + { + "resortCode": "HIGH1", + "code": "CAM_15", + "name": "밸리 베이스", + "sourceType": "HLS", + "sourceUrl": "/api/high1-stream/18/playlist.m3u8", + "externalPageUrl": "https://www.high1.com/webcam/pop_webcam.do?ch=18", + "displayOrder": 15 + }, + { + "resortCode": "MUJU", + "code": "CAM_01", + "name": "만선하우스", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/01/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=01", + "displayOrder": 1 + }, + { + "resortCode": "MUJU", + "code": "CAM_02", + "name": "만선봉 정상", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/02/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=02", + "displayOrder": 2 + }, + { + "resortCode": "MUJU", + "code": "CAM_03", + "name": "하이디하우스", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/03/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=03", + "displayOrder": 3 + }, + { + "resortCode": "MUJU", + "code": "CAM_04", + "name": "서역기행, 썬다운", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/04/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=04", + "displayOrder": 4 + }, + { + "resortCode": "MUJU", + "code": "CAM_05", + "name": "설천하우스", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/05/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=05", + "displayOrder": 5 + }, + { + "resortCode": "MUJU", + "code": "CAM_06", + "name": "설천 상단 슬로프", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/06/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=06", + "displayOrder": 6 + }, + { + "resortCode": "MUJU", + "code": "CAM_07", + "name": "설천봉 정상", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/07/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=07", + "displayOrder": 7 + }, + { + "resortCode": "MUJU", + "code": "CAM_08", + "name": "모차르트, 미뉴에트", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/08/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=08", + "displayOrder": 8 + }, + { + "resortCode": "MUJU", + "code": "CAM_09", + "name": "폴카", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/09/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=09", + "displayOrder": 9 + }, + { + "resortCode": "MUJU", + "code": "CAM_10", + "name": "실크로드, 미뉴에트 하단", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/10/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=10", + "displayOrder": 10 + }, + { + "resortCode": "MUJU", + "code": "CAM_11", + "name": "커넥션", + "sourceType": "HLS", + "sourceUrl": "/api/muju-stream/11/playlist.m3u8", + "externalPageUrl": "http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=11", + "displayOrder": 11 + }, + { + "resortCode": "EDEN_VALLEY", + "code": "CAM_01", + "name": "베이직", + "sourceType": "IFRAME", + "sourceUrl": "https://rtsp.me/embed/2kTsKt35/", + "externalPageUrl": null, + "displayOrder": 1 + }, + { + "resortCode": "EDEN_VALLEY", + "code": "CAM_02", + "name": "슬로프 광장", + "sourceType": "IFRAME", + "sourceUrl": "https://rtsp.me/embed/ry9aTdQh", + "externalPageUrl": null, + "displayOrder": 2 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_01", + "name": "스키하우스", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam0.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 1 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_02", + "name": "오렌지", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam1.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 2 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_03", + "name": "버금마루", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam2.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 3 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_04", + "name": "으뜸마루", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam3.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 4 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_05", + "name": "글로리2 상단", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam20.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 5 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_06", + "name": "글로리3 상단", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam21.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 6 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_07", + "name": "드림2 상단", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam22.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 7 + }, + { + "resortCode": "O2_RESORT", + "code": "CAM_08", + "name": "글로리3", + "sourceType": "HLS", + "sourceUrl": "/api/o2-stream/cam23.m3u8", + "externalPageUrl": "https://www.o2resort.com/ski/webcam.asp", + "displayOrder": 8 + } +] \ No newline at end of file diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java index 9dd47b6..2d880f8 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MasterDataControllerTest.java @@ -29,28 +29,46 @@ void setUp() { resortRepository.saveAll( java.util.List.of( com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("PHOENIX") .name("휘닉스파크") .regionName("강원 평창") + .displayOrder(1) + .active(true) .build(), com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("HIGH1") .name("하이원리조트") .regionName("강원 정선") + .displayOrder(2) + .active(true) .build(), com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("YONGPYONG") .name("모나용평") .regionName("강원 평창") + .displayOrder(3) + .active(true) .build(), com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("VIVALDI") .name("비발디파크") .regionName("강원 홍천") + .displayOrder(4) + .active(true) .build(), com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("WELLI_HILLI") .name("웰리힐리파크") .regionName("강원 횡성") + .displayOrder(5) + .active(true) .build(), com.ikae.snowthing.domain.member.entity.Resort.builder() + .code("JISAN") .name("지산리조트") .regionName("경기 이천") + .displayOrder(6) + .active(true) .build())); } if (ridingStyleRepository.count() == 0) { diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java index f9c9a94..38517df 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/controller/MemberProfileUpdateIntegrationTest.java @@ -64,8 +64,11 @@ void setUp() { () -> resortRepository.save( Resort.builder() + .code("PHOENIX") .name("휘닉스파크") .regionName("강원 평창") + .displayOrder(1) + .active(true) .build())); Resort r2 = resortRepository @@ -74,8 +77,11 @@ void setUp() { () -> resortRepository.save( Resort.builder() + .code("HIGH1") .name("하이원리조트") .regionName("강원 정선") + .displayOrder(2) + .active(true) .build())); resortId1 = r1.getId(); resortId2 = r2.getId(); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java b/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java index 70a8d9a..94125c8 100644 --- a/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java +++ b/backend/src/test/java/com/ikae/snowthing/domain/member/repository/MemberRepositoryCustomTest.java @@ -36,7 +36,14 @@ void findProfileByEmail_Success() { .bio("스노우보더") .build()); - Resort resort = Resort.builder().name("테스트하이원").regionName("강원 정선").build(); + Resort resort = + Resort.builder() + .code("TEST_HIGH1") + .name("테스트하이원") + .regionName("강원 정선") + .displayOrder(1) + .active(true) + .build(); entityManager.persist(resort); RidingStyle style = RidingStyle.builder().styleName("테스트파크").description("기물").build(); entityManager.persist(style); @@ -74,9 +81,23 @@ void findProfileByEmail_multipleCollections_success() { .bio("올라운더") .build()); - Resort resort1 = Resort.builder().name("테스트용평").regionName("강원 평창").build(); + Resort resort1 = + Resort.builder() + .code("TEST_YONGPYONG") + .name("테스트용평") + .regionName("강원 평창") + .displayOrder(1) + .active(true) + .build(); entityManager.persist(resort1); - Resort resort2 = Resort.builder().name("테스트휘닉스").regionName("강원 평창").build(); + Resort resort2 = + Resort.builder() + .code("TEST_PHOENIX") + .name("테스트휘닉스") + .regionName("강원 평창") + .displayOrder(2) + .active(true) + .build(); entityManager.persist(resort2); RidingStyle style1 = RidingStyle.builder().styleName("테스트라이딩").description("카빙").build(); diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponseTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponseTest.java new file mode 100644 index 0000000..aa030ab --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortcam/dto/ResortCameraGroupResponseTest.java @@ -0,0 +1,39 @@ +package com.ikae.snowthing.domain.resortcam.dto; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.ikae.snowthing.domain.resortcam.entity.CameraSourceType; + +class ResortCameraGroupResponseTest { + + @Test + @DisplayName("응답 DTO는 전달받은 카메라 목록을 방어적으로 복사한다") + void camerasAreDefensivelyCopied() { + List source = new ArrayList<>(); + source.add(camera("CAM_01")); + + ResortCameraGroupResponse response = + new ResortCameraGroupResponse("PHOENIX", "휘닉스파크", "강원 평창", 1, source); + source.add(camera("CAM_02")); + + org.assertj.core.api.Assertions.assertThat(response.cameras()).hasSize(1); + assertThatThrownBy(() -> response.cameras().add(camera("CAM_03"))) + .isInstanceOf(UnsupportedOperationException.class); + } + + private ResortCameraItemResponse camera(String code) { + return new ResortCameraItemResponse( + code, + "베이스", + CameraSourceType.HLS, + "https://example.com/live.m3u8", + "https://example.com/webcam", + 1); + } +} diff --git a/backend/src/test/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryServiceTest.java b/backend/src/test/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryServiceTest.java new file mode 100644 index 0000000..332dd51 --- /dev/null +++ b/backend/src/test/java/com/ikae/snowthing/domain/resortcam/service/ResortCameraQueryServiceTest.java @@ -0,0 +1,76 @@ +package com.ikae.snowthing.domain.resortcam.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.ikae.snowthing.domain.member.entity.Resort; +import com.ikae.snowthing.domain.member.repository.ResortRepository; +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraGroupResponse; +import com.ikae.snowthing.domain.resortcam.dto.ResortCameraListResponse; +import com.ikae.snowthing.domain.resortcam.entity.CameraSourceType; +import com.ikae.snowthing.domain.resortcam.entity.ResortCamera; +import com.ikae.snowthing.domain.resortcam.repository.ResortCameraRepository; + +@ExtendWith(MockitoExtension.class) +class ResortCameraQueryServiceTest { + + @Mock private ResortRepository resortRepository; + @Mock private ResortCameraRepository resortCameraRepository; + + private ResortCameraQueryService service; + + @BeforeEach + void setUp() { + service = new ResortCameraQueryService(resortRepository, resortCameraRepository); + } + + @Test + @DisplayName("활성 리조트 순서와 각 리조트의 활성 카메라 목록을 묶어 반환한다") + void getActiveResortCameras() { + Resort phoenix = resort("PHOENIX", "휘닉스파크", 1); + Resort high1 = resort("HIGH1", "하이원리조트", 2); + ResortCamera camera = camera(phoenix); + + given(resortRepository.findAllByActiveTrueOrderByDisplayOrderAscIdAsc()) + .willReturn(List.of(phoenix, high1)); + given(resortCameraRepository.findAllActiveOrdered()).willReturn(List.of(camera)); + + ResortCameraListResponse response = service.getActiveResortCameras(); + List result = response.resorts(); + + assertThat(result).extracting(ResortCameraGroupResponse::code).containsExactly("PHOENIX"); + assertThat(result.get(0).cameras()).hasSize(1); + } + + private Resort resort(String code, String name, int displayOrder) { + return Resort.builder() + .code(code) + .name(name) + .regionName("강원") + .displayOrder(displayOrder) + .active(true) + .build(); + } + + private ResortCamera camera(Resort resort) { + return ResortCamera.builder() + .resort(resort) + .code("CAM_01") + .name("베이스") + .sourceType(CameraSourceType.HLS) + .sourceUrl("https://example.com/live.m3u8") + .externalPageUrl("https://example.com/webcam") + .displayOrder(1) + .active(true) + .build(); + } +} diff --git a/database/production/005_migration_resort_cam.sql b/database/production/005_migration_resort_cam.sql new file mode 100644 index 0000000..3871dc8 --- /dev/null +++ b/database/production/005_migration_resort_cam.sql @@ -0,0 +1,144 @@ +-- ==================================================================== +-- Snowthing Production Migration +-- Version: 005 +-- Description: 리조트 코드 확장 및 리조트캠 마스터 데이터 추가 +-- ==================================================================== + +ALTER TABLE `resort` + ADD COLUMN `code` VARCHAR(30) NULL AFTER `resort_id`, + ADD COLUMN `display_order` INT NOT NULL DEFAULT 0 AFTER `region`, + ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT TRUE AFTER `display_order`; + +UPDATE `resort` SET `code` = 'PHOENIX', `display_order` = 1, `is_active` = TRUE WHERE `name` = '휘닉스파크'; +UPDATE `resort` SET `code` = 'VIVALDI', `display_order` = 2, `is_active` = TRUE WHERE `name` = '비발디파크'; +UPDATE `resort` SET `code` = 'HIGH1', `display_order` = 3, `is_active` = TRUE WHERE `name` = '하이원리조트'; +UPDATE `resort` SET `code` = 'YONGPYONG', `display_order` = 4, `is_active` = TRUE WHERE `name` = '모나용평'; +UPDATE `resort` SET `code` = 'WELLI_HILLI', `display_order` = 5, `is_active` = TRUE WHERE `name` = '웰리힐리파크'; +UPDATE `resort` SET `code` = 'JISAN', `display_order` = 6, `is_active` = TRUE WHERE `name` = '지산리조트'; + +INSERT INTO `resort` (`code`, `name`, `region`, `display_order`, `is_active`) VALUES +('KONJIAM', '곤지암리조트', '경기 광주', 7, TRUE), +('MUJU', '무주덕유산리조트', '전북 무주', 8, TRUE), +('EDEN_VALLEY', '에덴밸리리조트', '경남 양산', 9, TRUE), +('ELYSIAN', '엘리시안 강촌', '강원 춘천', 10, TRUE), +('ALPENSIA', '알펜시아리조트', '강원 평창', 11, TRUE), +('OAK_VALLEY', '오크밸리', '강원 원주', 12, TRUE), +('O2_RESORT', '오투리조트', '강원 태백', 13, TRUE) +ON DUPLICATE KEY UPDATE + `code` = VALUES(`code`), + `region` = VALUES(`region`), + `display_order` = VALUES(`display_order`), + `is_active` = VALUES(`is_active`); + +ALTER TABLE `resort` + MODIFY COLUMN `code` VARCHAR(30) NOT NULL, + ADD CONSTRAINT `uk_resort_code` UNIQUE (`code`), + ADD INDEX `idx_resort_active_order` (`is_active`, `display_order`, `resort_id`); + +CREATE TABLE `resort_camera` ( + `resort_camera_id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `resort_id` BIGINT NOT NULL, + `code` VARCHAR(50) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `source_type` VARCHAR(20) NOT NULL, + `source_url` VARCHAR(1000) NOT NULL, + `external_page_url` VARCHAR(1000) NULL, + `display_order` INT NOT NULL, + `is_active` BOOLEAN NOT NULL DEFAULT TRUE, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `fk_resort_camera_resort` FOREIGN KEY (`resort_id`) REFERENCES `resort` (`resort_id`), + CONSTRAINT `uk_resort_camera_resort_code` UNIQUE (`resort_id`, `code`), + CONSTRAINT `chk_resort_camera_source_type` CHECK (`source_type` IN ('HLS', 'YOUTUBE', 'IFRAME', 'EXTERNAL_LINK')), + CONSTRAINT `chk_resort_camera_display_order` CHECK (`display_order` >= 0), + INDEX `idx_resort_camera_active_order` (`resort_id`, `is_active`, `display_order`, `resort_camera_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +INSERT INTO `resort_camera` (`resort_id`, `code`, `name`, `source_type`, `source_url`, `external_page_url`, `display_order`, `is_active`) +SELECT r.resort_id, 'CAM_01', '정상 휴게소', 'HLS', 'https://video.fpkorea.com/konjiam/cam01.stream/playlist.m3u8', 'https://m.konjiamresort.co.kr/ski/skiLveCam.dev', 1, TRUE FROM resort r WHERE r.code = 'KONJIAM' UNION ALL +SELECT r.resort_id, 'CAM_02', '정상부 슬로프 (씽큐, 그램)', 'HLS', 'https://video.fpkorea.com/konjiam/cam02.stream/playlist.m3u8', 'https://m.konjiamresort.co.kr/ski/skiLveCam.dev', 2, TRUE FROM resort r WHERE r.code = 'KONJIAM' UNION ALL +SELECT r.resort_id, 'CAM_03', '초중급 베이스 (휘센)', 'HLS', 'https://video.fpkorea.com/konjiam/cam03.stream/playlist.m3u8', 'https://m.konjiamresort.co.kr/ski/skiLveCam.dev', 3, TRUE FROM resort r WHERE r.code = 'KONJIAM' UNION ALL +SELECT r.resort_id, 'CAM_04', '중상급 베이스 (와이낫, CNP)', 'HLS', 'https://video.fpkorea.com/konjiam/cam04.stream/playlist.m3u8', 'https://m.konjiamresort.co.kr/ski/skiLveCam.dev', 4, TRUE FROM resort r WHERE r.code = 'KONJIAM' UNION ALL +SELECT r.resort_id, 'CAM_05', '중간 슬로프 (와이낫 상단)', 'HLS', 'https://video.fpkorea.com/konjiam/cam05.stream/playlist.m3u8', 'https://m.konjiamresort.co.kr/ski/skiLveCam.dev', 5, TRUE FROM resort r WHERE r.code = 'KONJIAM' UNION ALL +SELECT r.resort_id, 'CAM_01', '레몬 탑승장', 'HLS', 'https://ant.livecity.co.kr:5443/jisancam/streams/jisan1.m3u8', 'https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp', 1, TRUE FROM resort r WHERE r.code = 'JISAN' UNION ALL +SELECT r.resort_id, 'CAM_02', '오렌지 / 뉴오렌지 탑승장', 'HLS', 'https://ant.livecity.co.kr:5443/jisancam/streams/jisan2.m3u8', 'https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp', 2, TRUE FROM resort r WHERE r.code = 'JISAN' UNION ALL +SELECT r.resort_id, 'CAM_03', '5번 / 6번 슬로프', 'HLS', 'https://ant.livecity.co.kr:5443/jisancam/streams/jisan3.m3u8', 'https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp', 3, TRUE FROM resort r WHERE r.code = 'JISAN' UNION ALL +SELECT r.resort_id, 'CAM_04', '블루 탑승장', 'HLS', 'https://ant.livecity.co.kr:5443/jisancam/streams/jisan4.m3u8', 'https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp', 4, TRUE FROM resort r WHERE r.code = 'JISAN' UNION ALL +SELECT r.resort_id, 'CAM_05', '실버 탑승장', 'HLS', 'https://ant.livecity.co.kr:5443/jisancam/streams/jisan5.m3u8', 'https://www.jisanresort.co.kr/m/ski/slopes/webcam.asp', 5, TRUE FROM resort r WHERE r.code = 'JISAN' UNION ALL +SELECT r.resort_id, 'CAM_01', '실시간 영상', 'YOUTUBE', 'https://www.youtube.com/embed/O6_8VBu4FSA', 'https://www.youtube.com/@11-lf8zw/streams', 1, TRUE FROM resort r WHERE r.code = 'ELYSIAN' UNION ALL +SELECT r.resort_id, 'CAM_01', '슬로프 전경', 'IFRAME', '/vivaldi.html?channel=8&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 1, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_02', '발라드 상단', 'IFRAME', '/vivaldi.html?channel=11&serial=TD0314A17496&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 2, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_03', '발라드 하단', 'IFRAME', '/vivaldi.html?channel=2&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 3, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_04', '재즈 상단', 'IFRAME', '/vivaldi.html?channel=4&serial=XU0121A37907&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 4, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_05', '재즈 하단', 'IFRAME', '/vivaldi.html?channel=1&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 5, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_06', '테크노 상단', 'IFRAME', '/vivaldi.html?channel=2&serial=XU0121A37904&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 6, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_07', '테크노 하단', 'IFRAME', '/vivaldi.html?channel=5&serial=XU0121A37904&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 7, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_08', '블루스', 'IFRAME', '/vivaldi.html?channel=4&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 8, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_09', '클래식', 'IFRAME', '/vivaldi.html?channel=10&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 9, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_10', '레게', 'IFRAME', '/vivaldi.html?channel=10&serial=TD0314A17496&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 10, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_11', '펑키 상단', 'IFRAME', '/vivaldi.html?channel=5&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 11, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_12', '펑키 하단', 'IFRAME', '/vivaldi.html?channel=8&serial=XU0121A37904&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 12, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_13', '힙합', 'IFRAME', '/vivaldi.html?channel=6&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 13, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_14', '스키월드 정상', 'IFRAME', '/vivaldi.html?channel=9&serial=TW0014A15451&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 14, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_15', '스노위랜드1', 'IFRAME', '/vivaldi.html?channel=16&serial=TD0314A17496&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 15, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_16', '스노위랜드2', 'IFRAME', '/vivaldi.html?channel=12&serial=TD0314A17496&autoplay=true', 'https://mice.sonohotelsresorts.com/daemyung.vp.utill.09_02_02_01.ds/dmparse.dm?areaType=S', 16, TRUE FROM resort r WHERE r.code = 'VIVALDI' UNION ALL +SELECT r.resort_id, 'CAM_01', '베이스', 'HLS', 'https://cctv-oak9.ktcdn.co.kr/cctv/ch2.stream/chunklist.m3u8', 'https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do', 1, TRUE FROM resort r WHERE r.code = 'OAK_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_02', 'I 슬로프', 'HLS', 'https://cctv-oak9.ktcdn.co.kr/cctv/ch9.stream/chunklist.m3u8', 'https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do', 2, TRUE FROM resort r WHERE r.code = 'OAK_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_03', 'G 슬로프 하단', 'HLS', 'https://cctv-oak9.ktcdn.co.kr/cctv/ch7.stream/chunklist.m3u8', 'https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do', 3, TRUE FROM resort r WHERE r.code = 'OAK_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_04', 'F 슬로프 상단', 'HLS', 'https://cctv-oak9.ktcdn.co.kr/cctv/ch6.stream/chunklist.m3u8', 'https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do', 4, TRUE FROM resort r WHERE r.code = 'OAK_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_05', '플라워 휴게소', 'HLS', 'https://cctv-oak9.ktcdn.co.kr/cctv/ch5.stream/chunklist.m3u8', 'https://app.oakvalley.co.kr/mobileapp/reserve/ski/webcam.do', 5, TRUE FROM resort r WHERE r.code = 'OAK_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_01', '알파', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam02.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 1, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_02', '베이스', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam03.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 2, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_03', '슬로프 광장 / 전경', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam04.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 3, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_04', '정상 광장', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam05.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 4, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_05', '슬로프 전경', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam06.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 5, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_06', '워터플래닛', 'HLS', 'https://live.wellihillipark.com/wellihillipark/_definst_/cam07.stream/playlist.m3u8', 'https://m.wellihillipark.com/customer/webcam', 6, TRUE FROM resort r WHERE r.code = 'WELLI_HILLI' UNION ALL +SELECT r.resort_id, 'CAM_01', '호크 / 스패로우', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/yh_02.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 1, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_02', '도도', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/sp_01.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 2, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_03', '불새마루', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/ht_01.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 3, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_04', '베이스', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/bc_02.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 4, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_05', '펭귄', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/bc_01.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 5, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_06', '스노우 빌리지', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/yh_01.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 6, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_07', '몽블랑 정상', 'HLS', 'https://streaming.phoenixhnr.co.kr/hls/mb_03.m3u8', 'https://phoenixhnr.co.kr/page/pyeongchang/guide/operation/sketchMovie', 7, TRUE FROM resort r WHERE r.code = 'PHOENIX' UNION ALL +SELECT r.resort_id, 'CAM_01', '알펜시아 리조트 라이브캠', 'YOUTUBE', 'https://www.youtube.com/embed/I3azd8f2HhM', 'https://www.alpensia.com/guide/web-cam.do', 1, TRUE FROM resort r WHERE r.code = 'ALPENSIA' UNION ALL +SELECT r.resort_id, 'CAM_01', '발왕산 氣 스카이워크', 'HLS', 'https://live.yongpyong.co.kr/cam01/index.m3u8', 'https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do', 1, TRUE FROM resort r WHERE r.code = 'YONGPYONG' UNION ALL +SELECT r.resort_id, 'CAM_02', '발왕산 천년주목숲길', 'HLS', 'https://live.yongpyong.co.kr/cam02/index.m3u8', 'https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do', 2, TRUE FROM resort r WHERE r.code = 'YONGPYONG' UNION ALL +SELECT r.resort_id, 'CAM_03', '옐로우 슬로프', 'HLS', 'https://live.yongpyong.co.kr/cam11/index.m3u8', 'https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do', 3, TRUE FROM resort r WHERE r.code = 'YONGPYONG' UNION ALL +SELECT r.resort_id, 'CAM_04', '베이스 전경 / 레드 슬로프', 'HLS', 'https://live.yongpyong.co.kr/cam08/index.m3u8', 'https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do', 4, TRUE FROM resort r WHERE r.code = 'YONGPYONG' UNION ALL +SELECT r.resort_id, 'CAM_05', '모나용평 진입로', 'HLS', 'https://live.yongpyong.co.kr/cam05/index.m3u8', 'https://www.yongpyong.co.kr/kor/guide/realTimeNews/ypResortWebcam.do', 5, TRUE FROM resort r WHERE r.code = 'YONGPYONG' UNION ALL +SELECT r.resort_id, 'CAM_01', '하이원탑 (제우스1 입구)', 'HLS', '/api/high1-stream/1/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=1', 1, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_02', '하이원탑 (헤라2 입구)', 'HLS', '/api/high1-stream/2/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=2', 2, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_03', '마운틴허브 (스노우월드 입구)', 'HLS', '/api/high1-stream/4/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=4', 3, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_04', '마운틴허브 (아폴로3 입구)', 'HLS', '/api/high1-stream/5/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=5', 4, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_05', '마운틴허브 (아테나2 입구)', 'HLS', '/api/high1-stream/6/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=6', 5, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_06', '마운틴 베이스 (아테나 리프트)', 'HLS', '/api/high1-stream/7/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=7', 6, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_07', '아테나2 하단', 'HLS', '/api/high1-stream/8/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=8', 7, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_08', '밸리허브 (헤라 리프트 입구)', 'HLS', '/api/high1-stream/11/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=11', 8, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_09', '밸리허브 (아폴로3 합류)', 'HLS', '/api/high1-stream/12/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=12', 9, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_10', '밸리허브 (제우스3)', 'HLS', '/api/high1-stream/13/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=13', 10, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_11', '제우스3 중단', 'HLS', '/api/high1-stream/14/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=14', 11, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_12', '아폴로4 중단', 'HLS', '/api/high1-stream/15/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=15', 12, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_13', '아폴로 베이스', 'HLS', '/api/high1-stream/16/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=16', 13, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_14', '제우스3 하단', 'HLS', '/api/high1-stream/17/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=17', 14, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_15', '밸리 베이스', 'HLS', '/api/high1-stream/18/playlist.m3u8', 'https://www.high1.com/webcam/pop_webcam.do?ch=18', 15, TRUE FROM resort r WHERE r.code = 'HIGH1' UNION ALL +SELECT r.resort_id, 'CAM_01', '만선하우스', 'HLS', '/api/muju-stream/01/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=01', 1, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_02', '만선봉 정상', 'HLS', '/api/muju-stream/02/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=02', 2, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_03', '하이디하우스', 'HLS', '/api/muju-stream/03/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=03', 3, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_04', '서역기행, 썬다운', 'HLS', '/api/muju-stream/04/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=04', 4, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_05', '설천하우스', 'HLS', '/api/muju-stream/05/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=05', 5, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_06', '설천 상단 슬로프', 'HLS', '/api/muju-stream/06/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=06', 6, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_07', '설천봉 정상', 'HLS', '/api/muju-stream/07/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=07', 7, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_08', '모차르트, 미뉴에트', 'HLS', '/api/muju-stream/08/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=08', 8, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_09', '폴카', 'HLS', '/api/muju-stream/09/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=09', 9, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_10', '실크로드, 미뉴에트 하단', 'HLS', '/api/muju-stream/10/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=10', 10, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_11', '커넥션', 'HLS', '/api/muju-stream/11/playlist.m3u8', 'http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=11', 11, TRUE FROM resort r WHERE r.code = 'MUJU' UNION ALL +SELECT r.resort_id, 'CAM_01', '베이직', 'IFRAME', 'https://rtsp.me/embed/2kTsKt35/', NULL, 1, TRUE FROM resort r WHERE r.code = 'EDEN_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_02', '슬로프 광장', 'IFRAME', 'https://rtsp.me/embed/ry9aTdQh', NULL, 2, TRUE FROM resort r WHERE r.code = 'EDEN_VALLEY' UNION ALL +SELECT r.resort_id, 'CAM_01', '스키하우스', 'HLS', '/api/o2-stream/cam0.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 1, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_02', '오렌지', 'HLS', '/api/o2-stream/cam1.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 2, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_03', '버금마루', 'HLS', '/api/o2-stream/cam2.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 3, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_04', '으뜸마루', 'HLS', '/api/o2-stream/cam3.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 4, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_05', '글로리2 상단', 'HLS', '/api/o2-stream/cam20.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 5, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_06', '글로리3 상단', 'HLS', '/api/o2-stream/cam21.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 6, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_07', '드림2 상단', 'HLS', '/api/o2-stream/cam22.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 7, TRUE FROM resort r WHERE r.code = 'O2_RESORT' UNION ALL +SELECT r.resort_id, 'CAM_08', '글로리3', 'HLS', '/api/o2-stream/cam23.m3u8', 'https://www.o2resort.com/ski/webcam.asp', 8, TRUE FROM resort r WHERE r.code = 'O2_RESORT'; diff --git a/docs/conception/003_resort_cam/01_requirements.md b/docs/conception/003_resort_cam/01_requirements.md new file mode 100644 index 0000000..4f018bb --- /dev/null +++ b/docs/conception/003_resort_cam/01_requirements.md @@ -0,0 +1,53 @@ +# 리조트캠 요구사항 + +## 1. 목적 + +사용자가 여러 리조트 공식 사이트를 각각 방문하지 않고 SnowThing에서 한 리조트의 웹캠을 한 화면에 확인하도록 합니다. 리조트와 카메라를 URL로 식별하여 공유, 새로고침, 브라우저 이동을 지원합니다. + +## 2. 사용자 시나리오 + +1. 비로그인 방문자가 리조트캠에 진입합니다. +2. 첫 번째 활성 리조트 또는 URL로 지정한 리조트가 선택됩니다. +3. 해당 리조트의 활성 카메라가 표시 순서대로 나타납니다. +4. PC에서는 화면에 진입한 카메라만 음소거 자동재생합니다. +5. 모바일에서는 사용자가 선택한 카메라만 재생합니다. +6. 카메라명을 선택하면 단일 카메라 화면으로 이동합니다. +7. 재생 실패 시 재시도하거나 원본 페이지를 새 탭으로 엽니다. + +## 3. 기능 요구사항 + +| ID | 요구사항 | +|---|---| +| RC-F-001 | 활성 리조트와 활성 카메라 메타데이터를 공개 API로 제공합니다. | +| RC-F-002 | 리조트 선택 시 해당 리조트의 모든 카메라 카드를 표시합니다. | +| RC-F-003 | 카메라 선택 시 해당 카메라만 크게 표시합니다. | +| RC-F-004 | 리조트·카메라 선택 상태를 URL에 반영합니다. | +| RC-F-005 | HLS, YouTube, iframe, 외부 링크 유형을 구분합니다. | +| RC-F-006 | 외부 영상 오류가 다른 카메라 렌더링에 영향을 주지 않아야 합니다. | +| RC-F-007 | 재생 오류 카드에 재시도와 원본 링크를 제공합니다. | +| RC-F-008 | PC는 가시 영역 영상만 최대 6개까지 자동재생합니다. | +| RC-F-009 | 모바일은 자동재생하지 않습니다. | +| RC-F-010 | 리조트 변경과 컴포넌트 해제 시 플레이어 자원을 정리합니다. | + +## 4. 비기능 요구사항 + +- 영상 바이트는 SnowThing 서버를 통과하지 않습니다. +- 최초 API 응답은 영상이 아닌 메타데이터만 포함합니다. +- 외부 URL 장애가 SnowThing API 장애로 전파되지 않아야 합니다. +- API DTO의 컬렉션은 `List.copyOf()`로 방어적 복사합니다. +- 서비스는 웹 세션이나 `SecurityContextHolder`를 직접 조작하지 않습니다. +- 컨트롤러는 Repository를 직접 호출하거나 Entity를 반환하지 않습니다. +- 예외는 `ErrorCode`와 `CustomException`으로 처리합니다. + +## 5. 초기 리조트 + +`PHOENIX`, `VIVALDI`, `HIGH1`, `YONGPYONG`, `WELLI_HILLI`, `JISAN`, `KONJIAM`, `MUJU`, `EDEN_VALLEY`, `ELYSIAN`, `ALPENSIA`, `OAK_VALLEY` + +## 6. 완료 조건 + +- 12개 리조트가 코드 기반으로 조회됩니다. +- 리조트 전체 보기와 개별 카메라 URL이 직접 접근 가능합니다. +- 비로그인 상태에서 동일하게 동작합니다. +- PC와 모바일 재생 정책이 구분됩니다. +- 운영 마이그레이션을 반복 실행해도 데이터가 중복되지 않습니다. + diff --git a/docs/conception/003_resort_cam/02_domain-model.md b/docs/conception/003_resort_cam/02_domain-model.md new file mode 100644 index 0000000..965a09c --- /dev/null +++ b/docs/conception/003_resort_cam/02_domain-model.md @@ -0,0 +1,54 @@ +# 리조트캠 도메인 모델 + +## 1. Resort 확장 + +기존 회원 선호 리조트 마스터인 `Resort`를 재사용합니다. 카메라 테이블에 리조트명과 지역을 중복 저장하지 않습니다. + +| 속성 | 타입 | 규칙 | +|---|---|---| +| id | Long | 내부 PK | +| code | String(30) | 변경 불가 업무 식별자, UNIQUE | +| name | String(100) | 표시명, 기존 UNIQUE 유지 | +| regionName | String(50) | 지역 | +| displayOrder | int | 0 이상 | +| active | boolean | 공개 마스터 노출 여부 | + +URL과 API에는 숫자 PK가 아닌 소문자 변환한 `code`를 사용합니다. 회원 선호 리조트 FK는 기존 `resort_id`를 그대로 유지합니다. + +## 2. ResortCamera + +| 속성 | 타입 | 규칙 | +|---|---|---| +| id | Long | 내부 PK | +| resort | Resort | 필수 N:1 | +| code | String(50) | 리조트 내부 고정 코드 | +| name | String(100) | 화면 표시명 | +| sourceType | CameraSourceType | 필수 | +| sourceUrl | String(1000) | HTTPS 영상 또는 페이지 URL | +| externalPageUrl | String(1000) | 실패 시 이동할 원본 페이지, nullable | +| displayOrder | int | 0 이상 | +| active | boolean | 일반 조회 포함 여부 | +| createdAt | LocalDateTime | 생성 시각 | +| updatedAt | LocalDateTime | 수정 시각 | + +`(resort_id, code)`는 UNIQUE입니다. `sourceUrl`은 비밀정보가 아니며 공개 API로 전달됩니다. + +## 3. CameraSourceType + +| 값 | 의미 | +|---|---| +| HLS | `.m3u8` 기반 스트림 | +| YOUTUBE | YouTube 임베드 | +| IFRAME | 삽입을 허용하는 외부 페이지 | +| EXTERNAL_LINK | 내부 재생 없이 원본 페이지 이동 | + +재생 구현 자체가 유형에 의존하므로 관리자가 임의 문자열을 추가하는 DB 마스터가 아니라 코드 Enum으로 관리합니다. + +## 4. 불변식 + +- 비활성 리조트의 카메라는 활성 상태여도 공개되지 않습니다. +- 카메라 코드는 같은 리조트 안에서 중복될 수 없습니다. +- 표시 순서는 음수가 될 수 없습니다. +- 내부 재생 유형의 URL은 HTTPS여야 합니다. +- 리조트나 카메라가 없을 때 문자열 비교 예외를 만들지 않고 정적 `ErrorCode`를 사용합니다. + diff --git a/docs/conception/003_resort_cam/03_erd.md b/docs/conception/003_resort_cam/03_erd.md new file mode 100644 index 0000000..6bcbcfb --- /dev/null +++ b/docs/conception/003_resort_cam/03_erd.md @@ -0,0 +1,52 @@ +# 리조트캠 ERD + +```mermaid +erDiagram + RESORT ||--o{ RESORT_CAMERA : contains + RESORT ||--o{ MEMBER_RESORT : preferred_by + + RESORT { + BIGINT resort_id PK + VARCHAR code UK + VARCHAR name UK + VARCHAR region + INT display_order + BOOLEAN active + } + + RESORT_CAMERA { + BIGINT resort_camera_id PK + BIGINT resort_id FK + VARCHAR code + VARCHAR name + VARCHAR source_type + VARCHAR source_url + VARCHAR external_page_url NULL + INT display_order + BOOLEAN active + DATETIME created_at + DATETIME updated_at + } +``` + +## 제약조건과 인덱스 + +```sql +UNIQUE KEY uk_resort_code (code) +UNIQUE KEY uk_resort_camera_code (resort_id, code) +KEY idx_resort_camera_visible (resort_id, active, display_order, resort_camera_id) +CHECK (display_order >= 0) +``` + +`member_resort`는 기존 관계를 유지합니다. 신규 리조트 추가는 기존 회원 데이터에 영향을 주지 않습니다. + +## 마이그레이션 순서 + +1. `resort.code`, `display_order`, `active`를 호환 가능한 상태로 추가합니다. +2. 기존 6개 리조트의 코드를 이름으로 식별해 채웁니다. +3. 신규 6개 리조트를 코드와 함께 삽입합니다. +4. 누락과 중복을 검증한 뒤 `code NOT NULL`과 UNIQUE를 적용합니다. +5. `resort_camera`를 생성합니다. +6. `SELECT resort_id FROM resort WHERE code = ...` 방식으로 카메라 FK를 연결합니다. +7. `(resort_id, code)` 기준으로 재실행 안전성을 보장합니다. + diff --git a/docs/conception/003_resort_cam/04_api-spec.md b/docs/conception/003_resort_cam/04_api-spec.md new file mode 100644 index 0000000..b856bc4 --- /dev/null +++ b/docs/conception/003_resort_cam/04_api-spec.md @@ -0,0 +1,52 @@ +# 리조트캠 API 명세 + +## GET `/api/v1/resort-cams` + +활성 리조트와 활성 카메라를 표시 순서대로 반환합니다. 인증은 요구하지 않습니다. + +### 200 OK + +```json +{ + "resorts": [ + { + "code": "PHOENIX", + "name": "휘닉스 파크", + "regionName": "강원 평창", + "displayOrder": 1, + "cameras": [ + { + "code": "PENGUIN", + "name": "펭귄 슬로프", + "sourceType": "HLS", + "sourceUrl": "https://stream.example/camera.m3u8", + "externalPageUrl": "https://official.example/webcam", + "displayOrder": 1 + } + ] + } + ] +} +``` + +### 응답 규칙 + +- 리조트: `displayOrder ASC, id ASC` +- 카메라: `displayOrder ASC, id ASC` +- 비활성 리조트와 카메라는 제외 +- 활성 카메라가 없는 리조트는 이번 API에서 제외 +- `resorts`와 `cameras`는 생성자에서 `List.copyOf()` 적용 +- Entity를 직접 직렬화하지 않고 전용 응답 DTO 사용 + +### 캐시 + +메타데이터는 자주 변경되지 않으므로 `Cache-Control: public, max-age=300`을 적용할 수 있습니다. 세션이나 회원별 값이 포함되지 않습니다. CDN 캐시는 배포 환경의 쿠키 전달 정책을 확인한 뒤 활성화합니다. + +### 오류 + +이 API는 목록형이므로 데이터가 없으면 404가 아니라 빈 배열을 반환합니다. DB 조회 실패만 공통 `INTERNAL_SERVER_ERROR`로 처리합니다. + +## 관리 API + +등록·수정·삭제·순서 변경 API는 이번 범위에 포함하지 않습니다. 추후 관리자 도메인 설계 시 별도 명세를 작성합니다. + diff --git a/docs/conception/003_resort_cam/05_external-stream-policy.md b/docs/conception/003_resort_cam/05_external-stream-policy.md new file mode 100644 index 0000000..e50d28d --- /dev/null +++ b/docs/conception/003_resort_cam/05_external-stream-policy.md @@ -0,0 +1,63 @@ +# 외부 영상 및 접근 정책 + +## 1. 기본 원칙 + +- SnowThing은 공식 공개 영상의 위치 정보만 관리합니다. +- 영상 파일, 세그먼트, 캡처 이미지를 서버에 저장하지 않습니다. +- 백엔드는 외부 영상을 프록시하거나 재송출하지 않습니다. +- 브라우저가 외부 원본 호스트에 직접 연결합니다. +- 초기 링크 후보는 `ski-resort.kr`과 `ski.atik.kr`의 공개 목록을 참고하되 실제 저장값은 원본 영상 또는 공식 페이지 URL로 기록합니다. + +## 2. 호환성 판정 + +| 조건 | 처리 | +|---|---| +| HTTPS HLS와 CORS 허용 | HLS | +| 공식 YouTube 영상 | YOUTUBE | +| 외부 프레임 허용 | IFRAME | +| HTTP 전용, 프레임 차단, 토큰 의존 | EXTERNAL_LINK | + +타 사이트에서 재생된다는 이유만으로 SnowThing에서도 재생된다고 가정하지 않습니다. Origin, Referer, CORS, CSP, 만료 토큰을 실제 SnowThing HTTPS Origin에서 확인합니다. + +## 3. 브라우저 보안 + +- `sourceUrl`을 HTML 문자열로 삽입하지 않습니다. +- iframe에는 필요한 최소 `sandbox`와 `allow` 속성을 적용합니다. +- 새 탭은 `target="_blank" rel="noopener noreferrer"`를 사용합니다. +- CSP의 `connect-src`, `media-src`, `frame-src`에는 검증된 호스트만 추가합니다. +- URL에 인증 토큰이나 개인정보를 저장하지 않습니다. + +## 4. 장애 정책 + +- 외부 재생 실패를 백엔드 장애로 취급하지 않습니다. +- 자동 무한 재시도는 금지합니다. +- 사용자 재시도와 원본 페이지 이동을 제공합니다. +- 리조트 전환 시 HLS 인스턴스, 이벤트 리스너, iframe을 정리합니다. +- 비활성화는 DB 값으로 처리하며 행을 물리 삭제하지 않아도 됩니다. + +## 5. 운영 고지 + +화면에 다음 취지의 고지를 둡니다. + +> 각 영상은 리조트가 외부에서 제공하는 원본에 직접 연결됩니다. SnowThing은 영상을 저장하거나 재송출하지 않으며, 제공처 사정에 따라 영상이 중단되거나 변경될 수 있습니다. + +## 6. 1차 소스 분류 결과 + +기준일: 2026-09-28 + +| 리조트 | 카메라 수 | 1차 유형 | 처리 근거 | +|---|---:|---|---| +| 휘닉스파크 | 7 | EXTERNAL_LINK | 비시즌 원본이 404를 반환해 공식 페이지 이동으로 시작 | +| 비발디파크 | 16 | EXTERNAL_LINK | 원본 페이지 내부 전용 카메라 식별자 사용 | +| 하이원리조트 | 18 | EXTERNAL_LINK | 영상 원본이 HTTP 전용이어서 HTTPS 페이지에서 혼합 콘텐츠 차단 | +| 모나용평 | 12 | EXTERNAL_LINK | Origin 요청에서 401을 반환해 공식 페이지 이동으로 시작 | +| 웰리힐리파크 | 6 | HLS | 공식 HTTPS HLS 원본 | +| 지산리조트 | 5 | EXTERNAL_LINK | 원본 호스트 연결 timeout으로 공식 페이지 이동 | +| 곤지암리조트 | 5 | EXTERNAL_LINK | 영상 원본이 HTTP 전용이어서 공식 페이지로 이동 | +| 무주덕유산리조트 | 11 | EXTERNAL_LINK | HTTP 및 만료 서명 URL이므로 저장하지 않고 공식 페이지로 이동 | +| 에덴밸리리조트 | 2 | IFRAME | 외부 공개 임베드 페이지 사용 | +| 엘리시안 강촌 | 1 | YOUTUBE | 공식 YouTube 라이브 | +| 알펜시아리조트 | 1 | YOUTUBE | 공식 YouTube 라이브 | +| 오크밸리 | 5 | EXTERNAL_LINK | 원본 호스트가 502를 반환해 공식 페이지 이동 | + +총 89개이며 1차 활성 유형은 HLS 6개, YouTube 2개, iframe 2개, 외부 링크 79개입니다. 휘닉스·모나용평·지산·오크밸리의 HTTPS HLS 후보는 DB에 바로 노출하지 않고 공식 페이지 이동으로 시작합니다. 운영 시점에 정상 응답과 CORS를 다시 확인한 뒤 관리자 기능으로 HLS 전환 여부를 결정합니다. diff --git a/docs/conception/003_resort_cam/06_test-plan.md b/docs/conception/003_resort_cam/06_test-plan.md new file mode 100644 index 0000000..22d5123 --- /dev/null +++ b/docs/conception/003_resort_cam/06_test-plan.md @@ -0,0 +1,53 @@ +# 리조트캠 테스트 계획 + +## 1. 마이그레이션 + +- 기존 6개 리조트의 FK와 PK가 유지되는지 검증 +- 12개 리조트 코드의 누락·중복 검증 +- 카메라 `(resort_id, code)` 중복 방지 검증 +- 최초 적용과 재실행 결과가 동일한지 검증 +- 잘못된 `source_type`, 음수 순서, 고아 FK 거부 검증 + +## 2. 백엔드 + +- 비로그인 요청 200 +- 리조트와 카메라 정렬 +- 비활성 리조트와 카메라 제외 +- 카메라가 없는 리조트 제외 +- 빈 결과는 `resorts: []` +- DTO 내부 리스트 수정 시 `UnsupportedOperationException` +- Controller → Service → Repository 계층 준수 + +## 3. 프런트엔드 + +- `/resort-cam` 기본 리다이렉트 +- 리조트 전체 보기 직접 접근 +- 개별 카메라 직접 접근 +- 잘못된 리조트·카메라 코드의 안내 화면 +- 뒤로가기·앞으로가기와 활성 메뉴 동기화 +- 데스크톱 IntersectionObserver 진입·이탈 +- 동시 자동재생 최대 6개 +- 모바일 자동재생 금지 +- HLS 인스턴스와 이벤트 정리 +- HLS·YouTube·iframe·외부 링크 렌더러 분기 +- 오류 카드 재시도와 원본 링크 +- 다크 모드와 반응형 레이아웃 + +## 4. 외부 소스 호환성 + +초기 데이터별로 다음 결과를 기록합니다. + +```text +리조트 / 카메라 / 원본 호스트 / 예상 유형 / PC / 모바일 / fallback / 확인일 +``` + +외부 영상은 CI에서 안정적으로 재현되지 않으므로 단위 테스트에서는 플레이어 어댑터를 모킹하고, 실제 소스 검증은 별도 수동 체크리스트로 관리합니다. + +## 5. 회귀 검증 + +- 회원가입·프로필 리조트 선택이 12개 데이터에서도 동작 +- 기존 회원의 `member_resort` 관계 유지 +- 라이브톡 `ResortTag` 동작 유지 +- 홈과 상단 메뉴 링크 정상 이동 +- 백엔드 `spotlessCheck test`, 프런트엔드 `lint build` + diff --git a/docs/conception/003_resort_cam/07_frontend-spec.md b/docs/conception/003_resort_cam/07_frontend-spec.md new file mode 100644 index 0000000..de33d03 --- /dev/null +++ b/docs/conception/003_resort_cam/07_frontend-spec.md @@ -0,0 +1,59 @@ +# 리조트캠 프런트엔드 명세 + +## 1. 라우트 + +```text +/resort-cam 기본 리조트로 이동 +/resort-cam/[resortCode] 리조트 전체 보기 +/resort-cam/[resortCode]/[cameraCode] 개별 카메라 보기 +/resort /resort-cam 리다이렉트 +``` + +경로 코드는 소문자를 사용하고 API 코드와 비교할 때 대문자로 정규화합니다. + +## 2. 레이아웃 + +SnowThing 공통 최대 폭과 색상 토큰을 사용합니다. 리조트 선택은 콘텐츠 상단의 가로 스크롤 탐색으로 제공하며, 고정 사이드바나 별도 대시보드 스타일을 사용하지 않습니다. + +전체 보기는 반응형 1~3열 그리드, 개별 보기는 단일 대형 플레이어를 사용합니다. 재생 가능한 소스는 영상 중심 카드로 표시하고, `EXTERNAL_LINK`는 검은 빈 플레이어 대신 공식 페이지 이동 목적이 명확한 간결한 카드로 표시합니다. 모바일에서도 동일한 탐색 구조를 유지하고 가로 스크롤로 리조트를 선택합니다. + +## 3. 데이터 흐름 + +1. 리조트캠 레이아웃에서 공개 API를 한 번 조회합니다. +2. 메타데이터를 하위 라우트가 공유합니다. +3. 경로 코드로 리조트와 카메라를 선택합니다. +4. 라우트 전환은 Next.js `Link`를 사용합니다. +5. 메타데이터는 유지하고 실제 플레이어만 교체합니다. + +## 4. 플레이어 어댑터 + +```text +ResortCameraPlayer +├─ HlsCameraPlayer +├─ YoutubeCameraPlayer +├─ IframeCameraPlayer +└─ ExternalCameraCard +``` + +HLS는 Safari의 native HLS를 우선하고 그 외 지원 브라우저에서 `hls.js`를 사용합니다. 각 어댑터는 `mount`, `play`, `pause`, `destroy`, `retry` 생명주기를 동일한 상위 컴포넌트 계약으로 제공합니다. + +## 5. 재생 정책 + +- PC: 30% 이상 보이는 카드가 자동재생 후보 +- 자동재생은 `muted`, `playsInline` +- 후보 중 화면 위쪽 순서로 최대 6개 재생 +- 화면 이탈 시 pause +- 모바일: 카드 선택 전 플레이어를 만들지 않음 +- 개별 보기: 해당 플레이어 하나만 생성 + +## 6. 상태와 오류 + +`idle`, `loading`, `playing`, `paused`, `error`, `external-only` 상태를 사용합니다. 오류 카드에는 카메라명, 오류 안내, 재시도, 원본 링크를 유지하여 레이아웃 높이가 갑자기 줄지 않게 합니다. + +## 7. 접근성 + +- 리조트·카메라 메뉴는 키보드 이동 가능 +- 활성 항목에 `aria-current` +- 드로어에 포커스 이동과 Escape 닫기 +- 플레이어 제목을 제공하고 아이콘 단독 버튼에 `aria-label` +- 자동재생은 음소거 상태로만 허용 diff --git a/docs/conception/003_resort_cam/08_implementation-plan.md b/docs/conception/003_resort_cam/08_implementation-plan.md new file mode 100644 index 0000000..a071050 --- /dev/null +++ b/docs/conception/003_resort_cam/08_implementation-plan.md @@ -0,0 +1,54 @@ +# 리조트캠 구현 계획 + +본 문서는 구현 순서만 정의합니다. 각 단계 착수 전 사용자 확인을 받습니다. + +## 1단계: 소스 호환성 표 + +- 12개 리조트 카메라 후보 수집 +- SnowThing HTTPS Origin에서 HLS·CORS·iframe 호환성 확인 +- `source_type`, 코드, 이름, 순서, fallback URL 확정 + +## 2단계: 운영 마이그레이션 + +- `005_migration_resort_cam.sql` +- 기존 리조트 코드 백필 +- 신규 리조트와 `resort_camera` 생성 +- 반복 실행 및 제약 검증 스크립트 + +## 3단계: 백엔드 조회 + +- `Resort` 확장과 `ResortCamera` 엔티티 +- `CameraSourceType` +- Repository 정렬 조회 +- Service DTO 변환과 방어적 복사 +- 공개 Controller와 Security 허용 +- 단위·통합 테스트 + +## 4단계: 프런트엔드 기반 + +- `hls.js` 도입 +- 라우트와 공통 레이아웃 +- 리조트 탐색·모바일 드로어 +- 전체/개별 화면과 오류 UI + +## 5단계: 재생 제어 + +- 플레이어 어댑터 +- IntersectionObserver와 최대 6개 제한 +- 모바일 선택 재생 +- 라우트 변경 시 자원 정리 + +## 6단계: 기존 화면 연결 + +- `/resort` 리다이렉트 +- 상단 메뉴와 홈 링크 변경 +- 기존 가상 리조트 데이터 제거 + +## 7단계: 검증 + +- 마이그레이션 검증 +- 백엔드 전체 테스트와 Spotless +- 프런트엔드 ESLint와 production build +- PC·모바일 실제 외부 영상 수동 검증 +- 다크 모드와 오류 fallback 확인 + diff --git a/docs/conception/003_resort_cam/ADR-301-external-stream.md b/docs/conception/003_resort_cam/ADR-301-external-stream.md new file mode 100644 index 0000000..0be582f --- /dev/null +++ b/docs/conception/003_resort_cam/ADR-301-external-stream.md @@ -0,0 +1,33 @@ +# ADR-301 외부 리조트 영상 연결 방식 + +- 상태: Accepted +- 결정일: 2026-09-28 + +## 문제 + +리조트별로 영상 형식과 제공 정책이 다르며, 여러 카메라를 동시에 보여줄 때 SnowThing 서버의 자원과 외부 서비스 장애를 격리해야 합니다. + +## 후보 + +1. 프런트엔드 정적 URL: 단순하지만 링크 변경마다 배포가 필요합니다. +2. DB 메타데이터와 브라우저 직접 재생: 운영 변경이 쉽고 서버 영상 트래픽이 없습니다. +3. 서버 프록시·재송출: 재생 통제는 쉽지만 네트워크 비용, 장애 책임, 법적 부담이 큽니다. + +## 결정 + +DB 메타데이터와 브라우저 직접 재생을 채택합니다. 백엔드는 활성 리조트와 카메라의 코드·이름·재생 유형·URL만 반환합니다. + +## 근거 + +- 외부 링크 변경을 코드 배포와 분리할 수 있습니다. +- 단일 EC2가 영상 대역폭을 부담하지 않습니다. +- 리조트 하나의 장애가 SnowThing 백엔드 가용성에 직접 영향을 주지 않습니다. +- 향후 관리자 페이지로 마스터 데이터를 관리할 수 있습니다. + +## 대가와 대응 + +- CORS·CSP·Referer 제한: 호환성 표와 `EXTERNAL_LINK` fallback으로 대응합니다. +- 여러 스트림의 브라우저 부하: 지연 생성, 가시 영역 감지, PC 최대 6개, 모바일 선택 재생으로 제한합니다. +- 외부 URL 변경: DB 활성 상태와 URL을 나중에 관리자 기능에서 수정합니다. +- 타사 서비스 의존: 원본 제공처 고지와 부분 실패 UI를 둡니다. + diff --git a/docs/conception/003_resort_cam/README.md b/docs/conception/003_resort_cam/README.md new file mode 100644 index 0000000..5808b9f --- /dev/null +++ b/docs/conception/003_resort_cam/README.md @@ -0,0 +1,35 @@ +# 리조트캠 설계 문서 + +공식 리조트 웹캠을 SnowThing에서 리조트별로 모아 보는 공개 기능의 설계 문서입니다. SnowThing은 영상 원본을 저장하거나 중계하지 않고, 브라우저가 공개된 외부 영상 소스에 직접 연결합니다. + +## 문서 목록 + +1. [요구사항](01_requirements.md) +2. [도메인 모델](02_domain-model.md) +3. [ERD](03_erd.md) +4. [API 명세](04_api-spec.md) +5. [외부 영상 및 접근 정책](05_external-stream-policy.md) +6. [테스트 계획](06_test-plan.md) +7. [프런트엔드 명세](07_frontend-spec.md) +8. [구현 계획](08_implementation-plan.md) +9. [외부 영상 연결 아키텍처 ADR](ADR-301-external-stream.md) +10. [결정 요구](결정요구.md) + +## 확정 범위 + +- 비로그인 사용자를 포함한 공개 조회 +- 12개 국내 리조트와 소속 카메라의 DB 마스터 데이터 관리 +- 리조트 전체 카메라 그리드와 개별 카메라 화면 +- PC 가시 영역 음소거 자동재생, 모바일 사용자 선택 재생 +- HLS, YouTube, iframe, 외부 링크 재생 유형 +- 기존 `/resort`를 `/resort-cam`으로 연결 + +## 제외 범위 + +- 관리자 화면과 관리 API +- 즐겨찾기와 사용자 지정 4분할 +- 날씨, 영상 캡처, PIP +- 서버 프록시·재송출·녹화 +- 자동 링크 상태 점검 +- 슬로프 마스터 데이터 + diff --git "a/docs/conception/003_resort_cam/\352\262\260\354\240\225\354\232\224\352\265\254.md" "b/docs/conception/003_resort_cam/\352\262\260\354\240\225\354\232\224\352\265\254.md" new file mode 100644 index 0000000..ffdb173 --- /dev/null +++ "b/docs/conception/003_resort_cam/\352\262\260\354\240\225\354\232\224\352\265\254.md" @@ -0,0 +1,36 @@ +# 리조트캠 결정 요구 + +## 확정된 결정 + +- 브랜치: `feature/resort-cam` +- 설계 위치: `docs/conception/003_resort_cam` +- DB 마스터 데이터 방식 +- 기존 `Resort` 확장과 `ResortCamera` N:1 구조 +- 카메라 코드·이름 사용, 슬로프 마스터는 제외 +- 공개 조회, 관리자 기능은 추후 개발 +- 12개 리조트 지원 +- 리조트 전체 보기와 개별 카메라 보기 +- PC 가시 영역 음소거 자동재생, 최대 6개 +- 모바일 사용자 선택 재생 +- 영상 저장·프록시·재송출 금지 + +## 구현 전 확인할 운영 데이터 + +아래 항목은 제품 정책 결정이 아니라 외부 소스의 실제 호환성 확인 결과로 확정합니다. + +1. 카메라별 최종 `source_type` +2. SnowThing HTTPS Origin에서 재생 가능한 최종 `source_url` +3. 재생 실패 시 이동할 공식 `external_page_url` +4. 계절 종료 카메라와 비스키 카메라의 초기 활성 여부 +5. CSP에 추가할 외부 호스트 목록 + +호환되지 않는 소스는 임의 프록시로 우회하지 않고 `EXTERNAL_LINK`로 낮춥니다. + +## 후속 기능 + +- 관리자 CRUD와 순서 변경 +- 링크 상태 점검과 마지막 검증 시각 +- 회원 즐겨찾기 +- 사용자 지정 4분할 +- 날씨와 슬로프 운영 정보 +- 캡처와 PIP diff --git a/docs/project/work.md b/docs/project/work.md index ea5b3a3..a1c7a4d 100644 --- a/docs/project/work.md +++ b/docs/project/work.md @@ -1,3 +1,97 @@ +- **슬로프캠 백엔드 CI 통과 및 CodeRabbit 피드백 전수 조치 (2026-09-29)**: + - 상태: 구현, 테스트 통과, DB/시드/CI스크립트 동기화 완료 + - 내용: + - **CI 실패 원인 해결 (`scripts/verify-production-migration-005.sh`)**: + - 검증 기대값을 실제 마스터 데이터 기준(13개 리조트, 87개 카메라, HLS: 67, IFRAME: 18, YOUTUBE: 2, EXTERNAL_LINK: 0)으로 보정. + - **프로덕션 마이그레이션 SQL 보강 (`005_migration_resort_cam.sql`)**: + - 누락되었던 오투리조트(`O2_RESORT`) 1건 및 카메라 8건 DDL/DML 추가. + - 비발디파크 `CAM_09`(클래식)의 시리얼을 `TD0314A17496`에서 대명소노 공식 실측 시리얼인 `TW0014A15451`로 교정하여 `CAM_10`(레게)와의 스트림 중복 문제 해결 (프론트엔드 `fallback-data.ts` 동시 반영). + - **로컬 시드 데이터 정합성 보장 (`resort-cameras-local.json`)**: + - 예전 더미 데이터로 방치되었던 로컬 시드 JSON을 프로덕션 87개 카메라 마스터 스펙과 100% 일치하도록 전면 동기화. + - **DataInitializer 멱등성 및 고유키 충돌 방지 보강 (`DataInitializer.java`)**: + - `targetResorts`에 `O2_RESORT` 추가. + - `findByCode()`가 비어있을 때 `findByName()`으로 기존 행을 조회하여 메타데이터를 갱신(Update)하도록 하여 로컬 DB 재부팅 시 `name` UNIQUE 제약 충돌 방지. + - **빌드 및 테스트 검증**: + - 백엔드 `./gradlew spotlessCheck test` 전체 통과 및 프론트엔드 `npm run build` 성공. + +- **슬로프캠 스트림 연동 및 뷰어 UI 개편 PR #30 생성 (2026-09-29)**: + - 상태: PR 생성 완료 (`feature/resort-cam` ➔ `main`) + - PR URL: https://github.com/devikae/snowthing/pull/30 + - 제목: `feat: 슬로프캠 스트림 연동 및 뷰어 UI 개편` + - 내용: 하이원·비발디·오투·무주 등 슬로프캠 스트림 연동 및 프록시 파이프라인 구축, 가로 스크롤 제거 2줄 그리드(7열×2행) UI 개편, LIVE 마크 제거, DB 마이그레이션 반영. + +- **슬로프캠(리조트캠) 오투리조트(강원 태백) 8개소 신규 추가 및 2줄 그리드(7열×2행) 확장 (2026-09-29)**: + - 상태: 구현 및 DB/프론트엔드 반영, HLS 프록시 라우트 신설, 빌드 검증 완료 + - 내용: + - **오투리조트 (`O2_RESORT`, 8개소) 실시간 HLS 스트리밍 구축**: + - 오투리조트 원본 서버(`http://118.46.149.144:8080/ramdisk/`)의 비암호화 HTTP 및 CORS 누락 문제를 해결하기 위해 Next.js 스트림 프록시 라우트(`frontend/app/api/o2-stream/[file]/route.ts`) 신설. + - 2초 램디스크 청크 기반 초저지연 HLS 스트리밍 실측 검증(200 OK, `video/MP2T` 정상 수신). + - 스키하우스, 오렌지, 버금마루, 으뜸마루, 글로리2 상단, 글로리3 상단, 드림2 상단, 글로리3 총 8개 채널 인라인 실시간 송출. + - **리조트 선택 탭 2줄 유지 레이아웃 확장 (7열×2행)**: + - 오투리조트 추가로 전국 총 13개 스키장이 됨에 따라 탭 그리드를 `grid-template-columns: repeat(7, minmax(0, 1fr))`로 확장하여 가로 스크롤 없이 정확히 2줄(1줄 7개, 2줄 6개)을 유지. + - **DB 및 정적 Fallback 데이터 무손실(UTF-8) 동기화**: + - `resort` 테이블에 `O2_RESORT` (강원 태백, display_order: 13) 및 `resort_camera` 테이블에 8개 카메라 등록 완료. + - `fallback-data.ts` 정합성 동기화 및 Next.js 16 프로덕션 빌드 통과(`npm run build` 검증 완료). + +- **슬로프캠(리조트캠) 비발디파크 WESP 정품 스트리밍 엔진 교체 및 슬로프캠 전 채널 자동재생 확장 (2026-09-29)**: + - 상태: 구현 및 빌드 검증 완료 + - 내용: + - **비발디파크 (`VIVALDI`) WESP 정품 스트리밍 엔진 전면 교체**: + - 기존 가짜 자체 제작 SDK(`vivaldi.js`)의 PBKDF2 암호화 검증 실패 및 소켓 연결 불능 결함을 해결. + - 검증된 대명소노그룹 정품 WESP WebSocket SDK 번들(`frontend/public/vivaldi.js` 및 `vivaldi.html`)로 전면 교체. + - `CameraPlayer.tsx` 내 IFRAME `sandbox` 속성으로 인해 WESP 바이너리 WebSocket 연결이 차단되던 보안 제약을 제거(`allow="autoplay; fullscreen"` 적용). + - **슬로프캠 전 채널 끊김 없는 자동 재생(Auto-play) 지원**: + - 기존 뷰포트 내 상위 6개 카메라로 제한되던 `autoPlayCodes.slice(0, 6)` 제약을 해제하여 16개 전체 카메라가 스크롤 시 자동 송출되도록 개선. + - 모바일 환경(`min-width: 768px` 미만)에서 `desktop` 플래그로 인해 비디오가 검은 커버로 멈춰 있던 조건을 제거하여 모바일/PC 모두 화면에 들어오면 즉시 자동 스트리밍 시작. + - Next.js 16 프로덕션 빌드 통과(`npm run build` 검증 완료). + +- **슬로프캠(리조트캠) 리조트 선택 탭 가로 스크롤 제거 및 2줄 그리드(6열×2행) 개편 및 LIVE 마크 전면 제거 (2026-09-28)**: + - 상태: 구현 및 빌드 검증 완료 + - 내용: + - **가로 스크롤 UX 제거 및 한눈에 보기 지원**: + - 기존의 가로 스크롤 방식(`.resortNavTrack { overflow-x: auto }`)을 전면 제거. + - 전국 12개 스키장 리조트가 스크롤 없이 한눈에 들어오도록 `grid-template-columns: repeat(6, minmax(0, 1fr))` 2줄 그리드 구조로 개편. + - **녹색 LIVE 마크 및 상태 태그 전면 제거**: + - 리조트 선택 탭 내 녹색 `LIVE` 배지(`liveBadge`) 제거하여 2줄 그리드 내 텍스트 공간 확보 및 시각적 노이즈 제거. + - 개별 카메라 카드 헤더의 `statusLive` 배지 제거하여 카메라 타이틀 중심의 정돈된 카드 헤더 구성. + - **컴팩트 버튼 가독성 및 반응형 모바일 최적화**: + - 각 탭 내 텍스트 중앙 정렬(`justify-content: center`) 및 텍스트 오버플로우 방지(`tabName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }`). + - 모바일(768px 이하 및 480px 이하)에서도 2줄 그리드가 깨지지 않고 터치하기 편하도록 패딩, 폰트 크기 반응형 최적화. + - Next.js 16 프로덕션 빌드 통과(`npm run build` 검증 완료). + +- **슬로프캠(리조트캠) 하이원 15개소 및 모나용평 5개소 실시간 HLS 라이브 스트림 연동 정상화 (2026-09-28)**: + - 상태: 구현 및 DB/프론트엔드 반영, HLS 프록시 라우트 신설, 빌드 검증 완료 + - 내용: + - **하이원 리조트 (HIGH1)**: + - 기존 외부 링크(`EXTERNAL_LINK`) 형태에서 인라인 실시간 HLS 스트림(`/api/high1-stream/{ch}/playlist.m3u8`)으로 전면 전환. + - 하이원 Wowza 서버 원본(`http://59.30.12.195:1935`)의 HTTP Mixed Content 및 브라우저 보안 정책을 해결하기 위해 Next.js 스트림 프록시 라우트(`frontend/app/api/high1-stream/[ch]/[file]/route.ts`) 신설. + - 마스터 플레이리스트, 청크리스트, TS 세그먼트까지 `video/MP2T` 1080p 고화질 무제한 스트리밍 파이프라인 실측 검증(200 OK). + - 하이원탑(제우스1, 헤라2), 마운틴허브(스노우월드, 아폴로3, 아테나2), 마운틴 베이스, 아테나2 하단, 밸리허브(헤라, 아폴로3, 제우스3), 제우스3 중/하단, 아폴로4 중단, 아폴로 베이스, 밸리 베이스 총 15개 채널 전면 송출. + - **모나용평 (YONGPYONG)**: + - 기존 구 엔드포인트(`Ycam1/...`)의 `401 Unauthorized` 차단 문제를 공식 KT CDN 신규 엔드포인트(`https://live.yongpyong.co.kr/cam{XX}/index.m3u8`)로 전면 교체하여 해결. + - 발왕산 氣 스카이워크, 발왕산 천년주목숲길, 옐로우 슬로프, 베이스 전경/레드 슬로프, 모나용평 진입로 총 5개 채널 `200 OK` 및 `CORS: *` 즉각 인라인 재생 지원. + - **DB 및 정적 Fallback 데이터 동기화**: + - `frontend/app/resort-cam/fallback-data.ts` 및 MySQL `resort_camera` 테이블 내 하이원 15건, 모나용평 5건 무손실(UTF-8) 갱신 완료. + - Next.js 16 프로덕션 빌드 통과(`npm run build` 검증 완료). + +- **슬로프캠(리조트캠) 곤지암, 비발디파크, 무주덕유산리조트 실시간 스트림 연동 및 인라인 재생 정상화 (2026-09-28)**: + - 상태: 구현 및 DB/프론트엔드 반영, 빌드 검증 완료 + - 내용: + - **곤지암 리조트 (KONJIAM)**: + - 기존 외부 링크(`EXTERNAL_LINK`) 형태에서 유효 SSL 도메인(`video.fpkorea.com`)의 HLS 실시간 스트림(`cam01` ~ `cam05`)으로 전면 교체. + - 기존 `konjiam.live.cdn.cloudn.co.kr`의 SSL 인증서 도메인 불일치(`NET::ERR_CERT_COMMON_NAME_INVALID`) 원인을 밝혀내고 정식 인증서 도메인(`video.fpkorea.com`, CORS `*` 허용)으로 교체하여 브라우저 보안 차단 해결. + - 5개 카메라(정상 휴게소, 정상부 슬로프, 초중급 베이스, 중상급 베이스, 중간 슬로프) 모두 `200 OK` 1080p HLS 스트림 즉시 재생. + - **비발디파크 (VIVALDI)**: + - WESP 전용 WebSocket 바이너리 스트리밍 클라이언트(`frontend/public/vivaldi.js`) 및 경량 임베드 뷰어(`frontend/public/vivaldi.html`) 구축. + - 16개 슬로프 카메라(슬로프 전경, 발라드 상/하단, 재즈 상/하단, 테크노 상/하단, 블루스, 클래식, 레게, 펑키 상/하단, 힙합, 스키월드 정상, 스노위랜드 1/2)를 전용 IFRAME(`sourceType: "IFRAME"`)으로 매핑하여 외부 사이트 메뉴 찌그러짐 없이 순수 16:9 슬로프 영상으로 즉각 재생되도록 개선. + - **무주덕유산리조트 (MUJU)**: + - 무주 원본 CDN의 인증서 만료(`certificate has expired`), Mixed Content 및 토큰 인증(`?e=...&h=...`) 문제를 해결하기 위해 Next.js 실시간 프록시 엔드포인트(`frontend/app/api/muju-stream/[cam]/[file]/route.ts`) 구현. + - 무주 공식 팝업에서 실시간 토큰을 동적으로 파싱/캐싱(60초)하고, m3u8 플레이리스트 및 TS 비디오 청크를 브라우저에 안전하게 스트리밍 중계. + - 11개 슬로프 카메라(만선하우스, 설천하우스, 설천봉 정상, 서역기행 등)를 HLS(`/api/muju-stream/...`)로 전면 교체하여 30초 재생 제한 없이 무제한 인라인 시청 지원. + - **DB 및 정적 Fallback 데이터 정합성 보장**: + - `frontend/app/resort-cam/fallback-data.ts` 및 로컬 MySQL `snowthing` 데이터베이스 `resort_camera` 테이블 내 곤지암 5건, 비발디 16건, 무주 11건의 `source_type`과 `source_url`을 UTF-8 무손실 동기화 완료. + - Next.js 16 프로덕션 빌드 통과(`npm run build` 성공). + - **Sprint 05 PR #26 CI 테스트 보정 (2026-09-22)**: - GitHub Actions MySQL 환경에서 트리거가 발생시킨 JDBC 예외가 로컬과 다른 Spring 예외 타입으로 포장되는 차이를 확인했습니다. - 롤백 불변식 자체는 동일하므로 테스트가 특정 구현 예외(`JpaSystemException`)에 결합되지 않도록 `DataAccessException`으로 검증 범위를 조정했습니다. @@ -1681,3 +1775,42 @@ - 백엔드 CI와 배포 전 CI에 `004` 중고장터 마이그레이션 검증 단계를 추가했습니다. - CodeRabbit 지적 사항 반영 후 `./gradlew.bat spotlessCheck test`를 다시 실행해 포맷 검사와 백엔드 전체 테스트를 통과했습니다. - 로컬 Windows 환경에는 Bash 실행 파일이 없어 `verify-production-migration-004.sh`의 구문 검사는 실행하지 못했으며, PR의 Linux CI에서 검증합니다. + +## 리조트캠 설계 + +- 상태: IN PROGRESS +- 시작일: 2026-09-28 + +### 완료 + +- 최신 `origin/main`에서 `feature/resort-cam` 브랜치를 생성했습니다. +- 리조트캠 설계 문서를 관리할 `docs/conception/003_resort_cam` 폴더를 생성했습니다. +- `docs/conception/003_resort_cam`에 요구사항, 도메인 모델, ERD, 공개 API, 외부 영상 정책, 테스트 계획, 프런트엔드 명세, 구현 계획, ADR과 결정 요구 문서를 작성했습니다. +- 기존 `Resort`를 확장하고 `ResortCamera`를 N:1로 연결하며, HLS·YouTube·iframe·외부 링크를 재생 유형으로 구분하도록 설계했습니다. +- 비발디를 포함한 국내 12개 리조트, 리조트 전체 보기와 개별 카메라 경로, PC 가시 영역 최대 6개 자동재생과 모바일 선택 재생 정책을 확정했습니다. +- 영상은 브라우저가 원본에 직접 연결하고 SnowThing 서버는 저장·프록시·재송출하지 않도록 경계를 정했습니다. +- 관리자 화면·관리 API·즐겨찾기·4분할·날씨·자동 상태 점검은 후속 범위로 분리했습니다. +- 기존 6개 리조트의 PK와 회원 선호 FK를 유지하면서 코드·표시 순서·활성 상태를 백필하고, 신규 6개 리조트와 카메라 89개를 추가하는 `005_migration_resort_cam.sql`을 작성했습니다. +- 89개 원본 후보를 실측해 1차 활성 유형을 HLS 6개, YouTube 2개, iframe 2개, 외부 링크 79개로 분류했습니다. HTTP 전용·만료 토큰 의존 소스뿐 아니라 비시즌 확인에서 404·401·502·timeout이 발생한 원본도 내부 오류 카드 대신 공식 페이지 이동으로 보수적으로 처리했습니다. +- `ResortCamera` 도메인과 공개 조회 API `GET /api/v1/resort-cams`를 구현하고, 활성 데이터 정렬·빈 리조트 제외·응답 컬렉션 방어적 복사를 적용했습니다. +- 로컬 프로필에서는 12개 리조트와 카메라 89개를 초기화하도록 별도 시드 초기화기를 추가했습니다. +- `/resort-cam`, 리조트별 전체 화면, 개별 카메라 화면을 구현하고 기존 `/resort`는 새 화면으로 리다이렉트했습니다. +- PC에서는 화면에 들어온 카메라 중 최대 6개 후보만 자동 재생하고, 모바일에서는 사용자가 누른 영상만 재생하도록 구성했습니다. +- HLS·YouTube·iframe·외부 링크 렌더러와 재시도·원본 페이지 이동 UI를 구현하고, 리조트 전환 시 HLS 자원을 정리하도록 구성했습니다. +- 상단 메뉴와 홈의 웹캠 링크를 `/resort-cam`으로 변경했습니다. +- `005` 운영 마이그레이션의 이력·리조트 12개·카메라 89개·소스 유형을 검증하는 스크립트를 추가하고 CI 및 배포 전 검증 단계에 연결했습니다. + +### 이슈 + +- 구현 전에 카메라별 HTTPS·CORS·CSP·Referer 호환성을 SnowThing Origin에서 확인하고, 호환되지 않는 소스는 `EXTERNAL_LINK`로 처리해야 합니다. +- 기존 운영 DB의 회원 선호 리조트 FK를 유지하면서 `resort.code`를 채워야 하므로 운영 마이그레이션은 단계적 백필 후 `NOT NULL`·UNIQUE를 적용해야 합니다. +- 외부 영상 제공처의 CORS·CSP·Referer 정책은 예고 없이 바뀔 수 있으므로 내부 재생 성공을 영구 보장할 수 없습니다. 실패 카드의 원본 페이지 이동 경로를 유지해야 합니다. + +### 검증 + +- `./gradlew.bat spotlessApply test`로 리조트캠 단위 테스트와 기존 백엔드 전체 테스트를 통과했습니다. +- 테스트 종료 시 기존 Hibernate 스키마 정리 과정의 외래 키 제거 경고가 출력되지만 테스트 결과는 성공입니다. +- `npm run lint`를 오류와 경고 없이 통과했습니다. +- `npm run build`로 리조트캠 전체·리조트별·개별 카메라 동적 라우트를 포함한 production build를 통과했습니다. +- Docker MySQL 8 임시 DB에서 `003`·`004`·`005` 마이그레이션 최초 적용과 재실행 이력, 활성 리조트 12개, 카메라 89개, 4개 소스 유형을 검증했습니다. 검증용 DB는 완료 후 삭제했습니다. +- 별도 검증 DB와 백엔드 포트에서 비로그인 `GET /api/v1/resort-cams`가 200으로 리조트 12개와 카메라 89개를 반환하는지 확인하고 검증 환경을 정리했습니다. diff --git a/frontend/app/api/high1-stream/[ch]/[file]/route.ts b/frontend/app/api/high1-stream/[ch]/[file]/route.ts new file mode 100644 index 0000000..980d4ea --- /dev/null +++ b/frontend/app/api/high1-stream/[ch]/[file]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ ch: string; file: string }> } +) { + try { + const { ch, file } = await params; + const targetUrl = `http://59.30.12.195:1935/live/_definst_/ch${ch}.stream/${file}`; + + const res = await fetch(targetUrl, { + cache: "no-store", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + signal: AbortSignal.timeout(8000), + }); + + if (!res.ok) { + return new NextResponse(`High1 upstream error: ${res.status}`, { status: res.status }); + } + + const contentType = file.endsWith(".m3u8") + ? "application/vnd.apple.mpegurl" + : file.endsWith(".ts") + ? "video/MP2T" + : "application/octet-stream"; + + return new NextResponse(res.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Cache-Control": file.endsWith(".m3u8") ? "no-cache, no-store, must-revalidate" : "public, max-age=60", + }, + }); + } catch (error) { + return new NextResponse( + JSON.stringify({ error: "High1 stream unavailable", detail: String(error) }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/frontend/app/api/muju-stream/[cam]/[file]/route.ts b/frontend/app/api/muju-stream/[cam]/[file]/route.ts new file mode 100644 index 0000000..727c798 --- /dev/null +++ b/frontend/app/api/muju-stream/[cam]/[file]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; + +interface CacheEntry { + url: string; + expiresAt: number; +} + +const tokenCache = new Map(); + +async function getMujuM3u8Url(cam: string): Promise { + const cached = tokenCache.get(cam); + if (cached && Date.now() < cached.expiresAt) { + return cached.url; + } + + const paddedCam = cam.padStart(2, "0"); + const popupUrl = `http://www.mdysresort.com/guide/webcam_popup_jh.asp?cam_num=${paddedCam}`; + + const res = await fetch(popupUrl, { + cache: "no-store", + headers: { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + }, + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + throw new Error(`Failed to fetch Muju popup: HTTP ${res.status}`); + } + + const html = await res.text(); + const match = html.match(/src=["'](http:\/\/muju\.cache\.cdn\.cloudn\.co\.kr\/[^"']+)["']/); + if (!match || !match[1]) { + throw new Error("Could not find Muju m3u8 source URL in popup HTML"); + } + + const url = match[1]; + // 60초 캐싱 + tokenCache.set(cam, { url, expiresAt: Date.now() + 60 * 1000 }); + return url; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ cam: string; file: string }> } +) { + try { + const { cam, file } = await params; + const paddedCam = cam.padStart(2, "0"); + + if (file === "playlist.m3u8") { + const targetUrl = await getMujuM3u8Url(paddedCam); + const res = await fetch(targetUrl, { + cache: "no-store", + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + return new NextResponse(`Upstream error: ${res.status}`, { status: res.status }); + } + + const content = await res.text(); + return new NextResponse(content, { + headers: { + "Content-Type": "application/vnd.apple.mpegurl", + "Access-Control-Allow-Origin": "*", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); + } + + // chunklist or ts segment + const search = request.nextUrl.search; + const targetUrl = `http://muju.cache.cdn.cloudn.co.kr/mujuresort/_definst_/cam${paddedCam}.stream/${file}${search}`; + + const res = await fetch(targetUrl, { + cache: "no-store", + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + return new NextResponse(`Segment error: ${res.status}`, { status: res.status }); + } + + const contentType = file.endsWith(".m3u8") + ? "application/vnd.apple.mpegurl" + : file.endsWith(".ts") + ? "video/MP2T" + : "application/octet-stream"; + + return new NextResponse(res.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=60", + }, + }); + } catch (error) { + console.error("[MujuStreamProxy] Error:", error); + return new NextResponse( + JSON.stringify({ error: "Stream unavailable", detail: String(error) }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/frontend/app/api/o2-stream/[file]/route.ts b/frontend/app/api/o2-stream/[file]/route.ts new file mode 100644 index 0000000..e37e711 --- /dev/null +++ b/frontend/app/api/o2-stream/[file]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ file: string }> } +) { + try { + const { file } = await params; + const targetUrl = `http://118.46.149.144:8080/ramdisk/${file}`; + + const res = await fetch(targetUrl, { + cache: "no-store", + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", + }, + signal: AbortSignal.timeout(6000), + }); + + if (!res.ok) { + return new NextResponse(`O2 upstream error: ${res.status}`, { status: res.status }); + } + + const contentType = file.endsWith(".m3u8") + ? "application/vnd.apple.mpegurl" + : file.endsWith(".ts") + ? "video/MP2T" + : "application/octet-stream"; + + return new NextResponse(res.body, { + status: 200, + headers: { + "Content-Type": contentType, + "Access-Control-Allow-Origin": "*", + "Cache-Control": file.endsWith(".m3u8") ? "no-cache, no-store, must-revalidate" : "public, max-age=60", + }, + }); + } catch (error) { + return new NextResponse( + JSON.stringify({ error: "O2 stream unavailable", detail: String(error) }), + { + status: 502, + headers: { "Content-Type": "application/json" }, + } + ); + } +} diff --git a/frontend/app/components/SiteChrome.tsx b/frontend/app/components/SiteChrome.tsx index 435b141..8027159 100644 --- a/frontend/app/components/SiteChrome.tsx +++ b/frontend/app/components/SiteChrome.tsx @@ -143,8 +143,8 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { ))} - - 리조트 + + 슬로프캠 중고장터 @@ -194,7 +194,7 @@ export function TopNav({ active = "home" }: { active?: ActiveNav }) { {boardItems.map((item) => ( setMobileOpen(false)}>{item.label} ))} - setMobileOpen(false)}>리조트 + setMobileOpen(false)}>슬로프캠 setMobileOpen(false)}>중고장터 diff --git a/frontend/app/lib/api.ts b/frontend/app/lib/api.ts index 314a892..0f6540d 100644 --- a/frontend/app/lib/api.ts +++ b/frontend/app/lib/api.ts @@ -41,6 +41,9 @@ export const API_ENDPOINTS = { chat: { recent: `${API_V1_URL}/chat/recent`, }, + resortCams: { + list: `${API_V1_URL}/resort-cams`, + }, market: { categories: `${API_V1_URL}/market/categories`, preview: `${API_V1_URL}/market-listings/preview`, diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index b9da135..d34b172 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -180,14 +180,14 @@ export default function HomePage() {

전국 주요 스키장 실시간 슬로프 & 설질 현황 (10분 주기 갱신)

-
캠 전체보기 ›
+
슬로프캠 전체보기 ›
{resorts.map((resort) => (
{resort.name}{resort.status}{resort.temp}
슬로프 오픈
{resort.open} {resort.detail && `(${resort.detail})`}
신설 / 설질
{resort.snow}
리프트 혼잡도
{resort.crowd}
-
{resort.slopes}웹캠 보기 ›
+
{resort.slopes}웹캠 보기 ›
))}
diff --git a/frontend/app/resort-cam/CameraPlayer.tsx b/frontend/app/resort-cam/CameraPlayer.tsx new file mode 100644 index 0000000..0dc3c2d --- /dev/null +++ b/frontend/app/resort-cam/CameraPlayer.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { ResortCameraItem } from "./types"; +import styles from "./resortCam.module.css"; + +interface CameraPlayerProps { + camera: ResortCameraItem; + autoPlay: boolean; + focused?: boolean; +} + +export function CameraPlayer({ camera, autoPlay, focused = false }: CameraPlayerProps) { + const videoRef = useRef(null); + const [desktop, setDesktop] = useState(false); + const [manualPlay, setManualPlay] = useState(false); + const [failed, setFailed] = useState(false); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const media = window.matchMedia("(min-width: 768px)"); + const sync = () => setDesktop(media.matches); + sync(); + media.addEventListener("change", sync); + return () => media.removeEventListener("change", sync); + }, []); + + const shouldLoad = focused || manualPlay || autoPlay; + const officialUrl = camera.externalPageUrl || camera.sourceUrl || ""; + + useEffect(() => { + const video = videoRef.current; + if (camera.sourceType !== "HLS" || !video || !shouldLoad) return; + + let disposed = false; + let destroy: (() => void) | undefined; + setFailed(false); + setLoading(true); + + void (async () => { + try { + if (video.canPlayType("application/vnd.apple.mpegurl")) { + video.src = camera.sourceUrl; + video.addEventListener("loadedmetadata", () => setLoading(false), { once: true }); + video.addEventListener("error", () => { + setFailed(true); + setLoading(false); + }, { once: true }); + } else { + const { default: Hls } = await import("hls.js"); + if (disposed) return; + if (!Hls.isSupported()) { + setFailed(true); + setLoading(false); + return; + } + const hls = new Hls({ enableWorker: true, lowLatencyMode: true }); + hls.loadSource(camera.sourceUrl); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + setLoading(false); + void video.play().catch(() => undefined); + }); + hls.on(Hls.Events.ERROR, (_, data) => { + if (data.fatal) { + setFailed(true); + setLoading(false); + } + }); + destroy = () => hls.destroy(); + } + void video.play().catch(() => undefined); + } catch { + setFailed(true); + setLoading(false); + } + })(); + + return () => { + disposed = true; + video.pause(); + video.removeAttribute("src"); + video.load(); + destroy?.(); + }; + }, [camera.sourceType, camera.sourceUrl, manualPlay, shouldLoad]); + + // 순수 영상 iframe 전용 (vivaldi.html, rtsp.me 등) + const isDedicatedVideoIframe = + camera.sourceType === "IFRAME" || + (camera.sourceType === "EXTERNAL_LINK" && camera.sourceUrl.includes("pop_webcam.do")); + + return ( +
+ {/* 1. HLS 비디오 스트림 (정상 작동 중) */} + {camera.sourceType === "HLS" && shouldLoad && !failed && ( + <> +