feat(discover): 제외 목록 보기 + 복원 기능

- 목록 API status 파라미터(NEW|EXCLUDED|REGISTERED), POST /{id}/restore(→NEW)
- recommend.html: 헤더 '제외 목록' 토글로 EXCLUDED 채널 보기, 카드에 '복원' 버튼
  (제외 보기에선 등록/제외 대신 복원+채널이동만), 지역 탭은 그대로 적용

검증: 제외→EXCLUDED 목록 노출→복원→NEW 복귀 end-to-end 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 11:48:34 +09:00
parent 1d7d50f50b
commit fd18a8981a
2 changed files with 51 additions and 20 deletions

View File

@ -21,9 +21,9 @@ public class RecommendedChannelController {
private final ChannelService channelService; private final ChannelService channelService;
@GetMapping @GetMapping
@Operation(summary = "추천 채널 목록", description = "status=NEW 를 배율 내림차순으로 반환") @Operation(summary = "추천 채널 목록", description = "status(NEW|EXCLUDED|REGISTERED, 기본 NEW)를 배율 내림차순으로 반환")
public ApiResponse<List<RecommendedChannel>> list() { public ApiResponse<List<RecommendedChannel>> list(@RequestParam(defaultValue = "NEW") String status) {
return ApiResponse.ok(repository.findByStatusOrderByRatioDesc("NEW")); return ApiResponse.ok(repository.findByStatusOrderByRatioDesc(status));
} }
@PostMapping("/{id}/register") @PostMapping("/{id}/register")
@ -49,6 +49,17 @@ public class RecommendedChannelController {
return ApiResponse.ok(null); return ApiResponse.ok(null);
} }
@PostMapping("/{id}/restore")
@Operation(summary = "제외 복원", description = "EXCLUDED 를 다시 NEW(추천 목록)로 되돌린다")
@Transactional
public ApiResponse<Void> restore(@PathVariable Long id) {
RecommendedChannel rc = repository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("추천 채널을 찾을 수 없습니다: " + id));
rc.setStatus("NEW");
repository.save(rc);
return ApiResponse.ok(null);
}
@PostMapping("/run") @PostMapping("/run")
@Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행") @Operation(summary = "수동 발굴 실행", description = "스케줄과 동일한 발굴을 즉시 1회 실행")
public ApiResponse<Map<String, Object>> run() { public ApiResponse<Map<String, Object>> run() {

View File

@ -10,6 +10,7 @@
<p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p> <p class="sub">지역(KR·JP·US) 인기 Shorts에서 자동 발굴한 떡상 채널 — 구독자는 적은데 조회수가 터진 채널</p>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn btn-secondary" id="viewToggle" onclick="toggleView()"><i data-lucide="archive" style="width:15px;"></i> 제외 목록</button>
<button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button> <button class="btn btn-secondary" onclick="runDiscovery()"><i data-lucide="refresh-cw" style="width:15px;"></i> 지금 발굴</button>
</div> </div>
</div> </div>
@ -54,12 +55,21 @@
function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); } function fmt(n){ return (n==null)?'-':Number(n).toLocaleString(); }
let ALL_RECS = []; let ALL_RECS = [];
let CUR_REGION = ''; // ''=전체 let CUR_REGION = ''; // ''=전체
let VIEW_STATUS = 'NEW'; // 'NEW'(추천) | 'EXCLUDED'(제외 목록)
async function load(){ async function load(){
try { ALL_RECS = await api(API); renderGrid(); } try { ALL_RECS = await api(API + '?status=' + VIEW_STATUS); renderGrid(); }
catch(e){ document.getElementById('grid').innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; } catch(e){ document.getElementById('grid').innerHTML='<div style="color:#f87171;">불러오기 실패: '+esc(e.message)+'</div>'; }
} }
function toggleView(){
VIEW_STATUS = (VIEW_STATUS === 'NEW') ? 'EXCLUDED' : 'NEW';
const btn = document.getElementById('viewToggle');
btn.innerHTML = (VIEW_STATUS === 'EXCLUDED')
? '<i data-lucide="list" style="width:15px;"></i> 추천 목록'
: '<i data-lucide="archive" style="width:15px;"></i> 제외 목록';
load();
}
function setTab(btn){ function setTab(btn){
CUR_REGION = btn.dataset.region || ''; CUR_REGION = btn.dataset.region || '';
document.querySelectorAll('.rc-tab').forEach(b=>{ const on=(b===btn); b.classList.toggle('btn-primary',on); b.classList.toggle('btn-secondary',!on); }); document.querySelectorAll('.rc-tab').forEach(b=>{ const on=(b===btn); b.classList.toggle('btn-primary',on); b.classList.toggle('btn-secondary',!on); });
@ -67,26 +77,36 @@
} }
function renderGrid(){ function renderGrid(){
const grid=document.getElementById('grid'); const grid=document.getElementById('grid');
const excludedView = (VIEW_STATUS === 'EXCLUDED');
const list = CUR_REGION ? ALL_RECS.filter(c=>c.region===CUR_REGION) : ALL_RECS; const list = CUR_REGION ? ALL_RECS.filter(c=>c.region===CUR_REGION) : ALL_RECS;
if(!list.length){ grid.innerHTML='<div class="text-muted">'+(CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.')+'</div>'; return; } if(!list.length){
grid.innerHTML=list.map(c=> grid.innerHTML='<div class="text-muted">'
'<div class="rc-card" id="rc-'+c.id+'">' + (excludedView ? '제외한 채널이 없습니다.'
+ '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">' : (CUR_REGION ? (CUR_REGION+' 지역 추천 채널이 없습니다.') : '아직 발굴된 추천 채널이 없습니다. ‘지금 발굴’을 눌러보세요.'))
+ '<div class="rc-body">' + '</div>';
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>' return;
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>' }
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>' grid.innerHTML=list.map(c=>{
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>' const moveBtn = '<a class="btn btn-secondary px-3 py-2" href="https://www.youtube.com/channel/'+esc(c.channelId)+'" target="_blank" title="유튜브 채널로 이동"><i data-lucide="external-link" style="width:14px;"></i> 채널 이동</a>';
+ '<div class="rc-actions">' const actions = excludedView
+ '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' ? '<button class="btn btn-primary px-3 py-2" onclick="restore('+c.id+')"><i data-lucide="rotate-ccw" style="width:14px;"></i> 복원</button>' + moveBtn
+ '<a class="btn btn-secondary px-3 py-2" href="https://www.youtube.com/channel/'+esc(c.channelId)+'" target="_blank" title="유튜브 채널로 이동"><i data-lucide="external-link" style="width:14px;"></i> 채널 이동</a>' : '<button class="btn btn-primary px-3 py-2" onclick="register('+c.id+')"><i data-lucide="user-plus" style="width:14px;"></i> 내 채널 등록</button>' + moveBtn
+ '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>' + '<button class="btn btn-secondary px-3 py-2" onclick="exclude('+c.id+')"><i data-lucide="x" style="width:14px;"></i> 제외</button>';
+ '</div>' return '<div class="rc-card" id="rc-'+c.id+'">'
+ '</div></div>').join(''); + '<img class="rc-thumb" style="cursor:pointer;" src="'+esc(c.thumbnailUrl)+'" loading="lazy" data-vid="'+esc(c.topVideoId)+'" data-title="'+esc(c.topVideoTitle||c.channelTitle||'').replace(/"/g,'&quot;')+'" onclick="openVideoModal(this)">'
+ '<div class="rc-body">'
+ '<div class="font-bold" style="line-height:1.4;">'+esc(c.channelTitle)+'</div>'
+ '<div class="text-sm text-muted">👤 '+fmt(c.subscriberCount)+'명 · '+esc(c.region||'')+'</div>'
+ '<div class="mt-2"><span class="rc-ratio">🔥 배율 '+(c.ratio?Number(c.ratio).toFixed(1):'-')+'x</span> <span class="text-sm text-muted">👁 '+fmt(c.topVideoViewCount)+'</span></div>'
+ '<div class="text-sm text-muted mt-1" style="line-height:1.4;">'+esc(c.topVideoTitle||'')+'</div>'
+ '<div class="rc-actions">'+actions+'</div>'
+ '</div></div>';
}).join('');
if(window.lucide) lucide.createIcons(); if(window.lucide) lucide.createIcons();
} }
async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } } async function register(id){ try{ await api(API+'/'+id+'/register',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('등록 실패: '+e.message); } }
async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } } async function exclude(id){ try{ await api(API+'/'+id+'/exclude',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('제외 실패: '+e.message); } }
async function restore(id){ try{ await api(API+'/'+id+'/restore',{method:'POST'}); ALL_RECS=ALL_RECS.filter(c=>c.id!==id); renderGrid(); }catch(e){ alert('복원 실패: '+e.message); } }
function openVideoModal(el){ function openVideoModal(el){
const vid = el.dataset.vid || ''; const vid = el.dataset.vid || '';
document.getElementById('modalTitle').textContent = el.dataset.title || ''; document.getElementById('modalTitle').textContent = el.dataset.title || '';