package com.tasteby.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.tasteby.domain.Channel; import com.tasteby.security.AuthUtil; import com.tasteby.service.CacheService; import com.tasteby.service.ChannelService; import com.tasteby.service.YouTubeService; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/channels") public class ChannelController { private final ChannelService channelService; private final YouTubeService youtubeService; private final CacheService cache; private final ObjectMapper objectMapper; public ChannelController(ChannelService channelService, YouTubeService youtubeService, CacheService cache, ObjectMapper objectMapper) { this.channelService = channelService; this.youtubeService = youtubeService; this.cache = cache; this.objectMapper = objectMapper; } @GetMapping public List list() { String key = cache.makeKey("channels"); String cached = cache.getRaw(key); if (cached != null) { try { return objectMapper.readValue(cached, new TypeReference>() {}); } catch (Exception ignored) {} } var result = channelService.findAllActive(); cache.set(key, result); return result; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public Map create(@RequestBody Map body) { AuthUtil.requireAdmin(); String channelId = body.get("channel_id"); String channelName = body.get("channel_name"); String titleFilter = body.get("title_filter"); // #295 — body 필수값 가드 (NOT NULL 컬럼에 빈 값 들어가 500 나는 것 방지) if (channelId == null || channelId.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_id는 필수입니다"); } if (channelName == null || channelName.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "channel_name은 필수입니다"); } try { String id = channelService.create(channelId, channelName, titleFilter); // #333 — 전체 flush 대신 channels 키만 evict (다른 모듈 캐시 보존) cache.del(cache.makeKey("channels")); return Map.of("id", id, "channel_id", channelId); } catch (DataIntegrityViolationException e) { // #295 — 유니크 충돌을 메시지 문자열 매칭 대신 typed 예외로 감지 (제약명 변경에도 견고). throw new ResponseStatusException(HttpStatus.CONFLICT, "Channel already exists"); } } @PostMapping("/{channelId}/scan") public Map scan(@PathVariable String channelId, @RequestParam(defaultValue = "false") boolean full) { AuthUtil.requireAdmin(); var result = youtubeService.scanChannel(channelId, full); if (result == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found"); } cache.flush(); return result; } @PutMapping("/{id}") public Map update(@PathVariable String id, @RequestBody Map body) { AuthUtil.requireAdmin(); Integer sortOrder = body.get("sort_order") != null ? ((Number) body.get("sort_order")).intValue() : null; channelService.update(id, (String) body.get("description"), (String) body.get("tags"), sortOrder); cache.flush(); return Map.of("ok", true); } @DeleteMapping("/{channelId}") public Map delete(@PathVariable String channelId) { AuthUtil.requireAdmin(); if (!channelService.deactivate(channelId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Channel not found"); } cache.flush(); return Map.of("ok", true); } }