83c28db572
뷰어가 published 테이블을 rev 커서로 incremental pull 하는 read-only feed.
- GET /published/feed?since={rev}&kind=&limit= → rev>since ORDER BY rev ASC LIMIT(cap 500)
- Bearer(viewer_sync_token) default-deny + 상수시간 비교(internal_study 패턴 재사용)
- 엔벨로프 schema_version + items[pub_id·kind·source_id·rev·deleted·schema_version·payload]
+ next_since·has_more. tombstone(deleted=true) 1급 이벤트 포함.
- viewer_sync_token = Mac mini internal_worker_token 과 분리(폭발반경 격리), 기본 ""=default-deny.
rev 커서 안전 = 워커 단일 라이터(advisory lock) 배치 원자 커밋. 배포는 P0 seam
(P0-3 뷰어 pull-sync) 완성 후 일괄 게이트. read API = additive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
114 lines
4.5 KiB
Python
114 lines
4.5 KiB
Python
"""발행 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,
|
|
)
|