"""발행 read API (docsrv-viewer-publish P0-2) — 뷰어가 pull-sync 로 당기는 feed. published 테이블(발행 워커가 rev 커밋순 gapless 부여)을 rev 커서로 페이지네이션해 반환. 뷰어 = Bearer(settings.viewer_sync_token) 인증, default-deny. read-only(SELECT 만). GET /published/feed?since={rev}&kind={kind}&limit={n} rev > since 행을 rev ASC 로 limit 만큼. kind 옵션(study_question|study_explanation|... 후속). tombstone(deleted=true)도 1급 이벤트로 포함 — 뷰어가 pub_id 로 로컬 삭제(stale 회피). rev 커서 안전성: 워커가 pg_advisory_xact_lock 단일 라이터로 배치 rev 를 한 트랜잭션에 부여·커밋 → 리더는 rev N 을 N-1 없이 보지 못함(부분가시 0). 뷰어는 next_since 로 반복. 엔벨로프 schema_version = 전송 계약 버전(payload 행별 schema_version 과 별개). 미지원 버전 가시거부는 뷰어 책임(no-silent-fallback) — 여기선 행별 schema_version 그대로 전달. """ from __future__ import annotations import hmac import logging from fastapi import APIRouter, Depends, Header, HTTPException, Query from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from core.config import settings from core.database import async_session from models.published import Published logger = logging.getLogger(__name__) router = APIRouter() # feed 엔벨로프(전송 계약) 버전 — payload schema_version 과 독립. FEED_SCHEMA_VERSION = 1 DEFAULT_LIMIT = 200 MAX_LIMIT = 500 def _verify_token(authorization: str | None = Header(default=None)) -> None: """뷰어↔DS 발행 채널 Bearer 인증. default-deny(미설정=503). 상수시간 비교(internal_study 정본). 이 토큰은 정답 포함 study payload 를 노출하므로 hmac.compare_digest 로 timing side-channel 차단. """ if not settings.viewer_sync_token: raise HTTPException(status_code=503, detail="viewer_sync_token not configured") if not authorization or not authorization.lower().startswith("bearer "): raise HTTPException(status_code=401, detail="missing Bearer token") token = authorization[7:].strip() if not hmac.compare_digest(token, settings.viewer_sync_token): raise HTTPException(status_code=403, detail="invalid token") async def _session() -> AsyncSession: async with async_session() as s: yield s class FeedItem(BaseModel): pub_id: str # opaque+stable = 뷰어 dedup키 = progress키 kind: str source_id: int # DS 내부 소스 행 id (ingest write-back 역해소용, P2) rev: int deleted: bool # tombstone — 뷰어 로컬 삭제 트리거 schema_version: int # payload 모양 버전(뷰어 range 수용) payload: dict # render-ready projection (tombstone 이면 {}) class FeedResponse(BaseModel): schema_version: int # 엔벨로프(전송 계약) 버전 items: list[FeedItem] next_since: int # 다음 호출 since (이 배치 max rev; 빈 배치면 입력 since 유지) has_more: bool # limit 가득 = 더 있을 수 있음(뷰어 반복) @router.get("/feed", response_model=FeedResponse) async def published_feed( since: int = Query(0, ge=0), kind: str | None = Query(None, max_length=40), limit: int = Query(DEFAULT_LIMIT, ge=1, le=MAX_LIMIT), _auth: None = Depends(_verify_token), session: AsyncSession = Depends(_session), ): """rev > since 행을 rev ASC 로 limit 만큼 반환. 뷰어가 next_since 로 incremental pull.""" stmt = select(Published).where(Published.rev > since) if kind: stmt = stmt.where(Published.kind == kind) stmt = stmt.order_by(Published.rev.asc()).limit(limit) rows = (await session.execute(stmt)).scalars().all() items = [ FeedItem( pub_id=r.pub_id, kind=r.kind, source_id=r.source_id, rev=r.rev, deleted=r.deleted, schema_version=r.schema_version, payload=r.payload if r.payload is not None else {}, ) for r in rows ] next_since = items[-1].rev if items else since has_more = len(rows) == limit logger.info( "published_feed since=%s kind=%s returned=%s next_since=%s has_more=%s", since, kind, len(items), next_since, has_more, ) return FeedResponse( schema_version=FEED_SCHEMA_VERSION, items=items, next_since=next_since, has_more=has_more, )