155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
"""
|
|
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")
|