""" STT 이력 조회 라우터 모듈. GET /api/v1/records — 필터링·페이징·N+1 방지 Eager Loading 적용 GET /api/v1/segments/daily — 일자별 세그먼트 채팅뷰 API (Cursor 페이지네이션) """ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.orm import Session, joinedload from sqlalchemy import select, func, desc, asc from typing import Optional, List from datetime import datetime import logging from app.db.database import get_db from app.db.models import TranscriptionRecord, TranscriptionSegment from app.models.record import RecordListResponse, RecordPageResponse, DailySegmentResponse, DailySegmentPage router = APIRouter(prefix="/api/v1", tags=["기록 조회"]) logger = logging.getLogger("uvicorn.error") @router.get( "/records", response_model=RecordPageResponse, summary="STT 변환 이력 목록 조회", description=( "저장된 STT 변환 기록을 최신순으로 조회합니다. " "keyword·start_date·end_date 파라미터로 필터링하며 " "skip/limit으로 페이징할 수 있습니다." ) ) def get_records( skip: int = Query(default=0, ge=0, description="조회 시작 오프셋"), limit: int = Query(default=50, ge=1, le=200, description="한 페이지 최대 건수 (최대 200)"), keyword: Optional[str] = Query(default=None, description="full_text 에 포함된 검색 키워드"), start_date: Optional[datetime] = Query(default=None, description="base_datetime 시작 (ISO 8601)"), end_date: Optional[datetime] = Query(default=None, description="base_datetime 종료 (ISO 8601)"), db: Session = Depends(get_db), ): """ N+1 방지: joinedload(TranscriptionRecord.segments) 로 단 1번의 JOIN 쿼리로 Record + Segment를 모두 가져옵니다. """ filters = [] if keyword: filters.append(TranscriptionRecord.full_text.like(f"%{keyword}%")) if start_date: filters.append(TranscriptionRecord.base_datetime >= start_date) if end_date: filters.append(TranscriptionRecord.base_datetime <= end_date) count_stmt = select(func.count(TranscriptionRecord.id)).where(*filters) total: int = db.scalar(count_stmt) or 0 data_stmt = ( select(TranscriptionRecord) .where(*filters) .options(joinedload(TranscriptionRecord.segments)) .order_by(desc(TranscriptionRecord.id)) .offset(skip) .limit(limit) ) records = db.scalars(data_stmt).unique().all() logger.debug(f"[Records API] total={total}, returned={len(records)}, keyword={keyword!r}") return RecordPageResponse( total=total, skip=skip, limit=limit, records=[RecordListResponse.model_validate(r) for r in records] ) @router.get( "/segments/daily", response_model=DailySegmentPage, summary="일자별 전체 세그먼트 조회 (Cursor 페이지네이션)", description=( "특정 날짜(date=YYYY-MM-DD)에 저장된 TranscriptionSegment를 " "id 오름차순으로 반환합니다. " "cursor(마지막 id) + limit 파라미터로 무한 스크롤을 지원합니다. " "COUNT(*) 없이 인덱스만 사용하므로 대용량에서도 빠릅니다." ) ) def get_segments_daily( date: str = Query(..., description="조회 날짜 (YYYY-MM-DD, 예: 2026-03-07)"), cursor: Optional[int] = Query(default=None, description="마지막으로 받은 segment id (첫 요청 시 생략)"), limit: int = Query(default=100, ge=1, le=500, description="한 번에 받을 세그먼트 수 (기본 100)"), db: Session = Depends(get_db), ): """ Cursor 기반 페이지네이션 설계: - cursor=None: 첫 페이지 (id가 가장 작은 것부터) - cursor=N: id > N 조건으로 다음 페이지 (인덱스 스캔, COUNT(*) 없음) - limit+1 개를 가져와 has_more 판단 후 마지막 1건 제거 """ try: target_date = datetime.strptime(date, "%Y-%m-%d").date() except ValueError: raise HTTPException(status_code=400, detail="날짜 형식 오류: YYYY-MM-DD 형식으로 입력하세요.") logger.debug(f"[Daily API] date={target_date}, cursor={cursor}, limit={limit}") # 해당 날짜 record_id 서브쿼리 (created_at 기준) record_ids = list(db.scalars( select(TranscriptionRecord.id) .where(func.date(TranscriptionRecord.created_at) == target_date) ).all()) logger.debug(f"[Daily API] 해당 record_ids={record_ids}") if not record_ids: return DailySegmentPage(items=[], next_cursor=None, has_more=False) # Cursor 조건 구성 (id > cursor 로 인덱스 활용) seg_filters = [TranscriptionSegment.record_id.in_(record_ids)] if cursor is not None: seg_filters.append(TranscriptionSegment.id > cursor) # limit+1 조회 → has_more 판단 seg_query = ( select(TranscriptionSegment) .where(*seg_filters) .order_by(asc(TranscriptionSegment.id)) .limit(limit + 1) ) raw_segs = db.scalars(seg_query).all() has_more = len(raw_segs) > limit segs = raw_segs[:limit] next_cursor = segs[-1].id if has_more and segs else None logger.debug(f"[Daily API] returned={len(segs)}, has_more={has_more}, next_cursor={next_cursor}") return DailySegmentPage( items=[DailySegmentResponse.model_validate(s) for s in segs], next_cursor=next_cursor, has_more=has_more, ) import os from fastapi.responses import FileResponse @router.get( "/segments/{segment_id}/audio", summary="[Chapter 8.0] 세그먼트별 압축 오디오 재생 (보안 스트리밍 API)", description="DB에서 opus 오디오 경로를 읽어 반환합니다. 향후 인증 로직(Depends) 연동을 지원합니다." ) def get_segment_audio( segment_id: int, db: Session = Depends(get_db) ): seg = db.query(TranscriptionSegment).filter(TranscriptionSegment.id == segment_id).first() if not seg or not seg.audio_path or not os.path.exists(seg.audio_path): raise HTTPException(status_code=404, detail="오디오 파일을 찾을 수 없거나 삭제되었습니다.") return FileResponse(seg.audio_path, media_type="audio/ogg")