From 83c28db57230db0cd05ccc1e96d2bd2c9ef6199b Mon Sep 17 00:00:00 2001 From: hyungi Date: Wed, 24 Jun 2026 14:25:56 +0900 Subject: [PATCH] =?UTF-8?q?feat(publish):=20P0-2=20=EB=B0=9C=ED=96=89=20re?= =?UTF-8?q?ad=20API=20/published/feed=20(study=E2=86=92viewer=20pull-sync)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 뷰어가 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) --- app/api/published.py | 113 +++++++++++++++++++++++++++++++++++++++++++ app/core/config.py | 5 ++ app/main.py | 2 + 3 files changed, 120 insertions(+) create mode 100644 app/api/published.py diff --git a/app/api/published.py b/app/api/published.py new file mode 100644 index 0000000..2789b34 --- /dev/null +++ b/app/api/published.py @@ -0,0 +1,113 @@ +"""발행 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, + ) diff --git a/app/core/config.py b/app/core/config.py index cf97b32..8065c36 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -191,6 +191,9 @@ class Settings(BaseModel): # internal endpoint Bearer token (Mac mini derived-worker 호출용) internal_worker_token: str = "" + # 뷰어↔DS 발행 채널 Bearer token (publish read API P0-2 + ingest P2). Mac mini 토큰과 분리(폭발반경 격리). + viewer_sync_token: str = "" + def load_settings() -> Settings: """config.yaml + 환경변수에서 설정 로딩""" @@ -200,6 +203,7 @@ def load_settings() -> Settings: study_card_extract_enabled = os.getenv("STUDY_CARD_EXTRACT_ENABLED", "true").lower() in ("1", "true", "yes") study_publish_enabled = os.getenv("STUDY_PUBLISH_ENABLED", "false").lower() in ("1", "true", "yes") internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "") + viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "") jwt_secret = os.getenv("JWT_SECRET", "") totp_secret = os.getenv("TOTP_SECRET", "") eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "") @@ -334,6 +338,7 @@ def load_settings() -> Settings: study_card_extract_enabled=study_card_extract_enabled, study_publish_enabled=study_publish_enabled, internal_worker_token=internal_worker_token, + viewer_sync_token=viewer_sync_token, pipeline_held_stages=pipeline_held_stages, mlx_gate_concurrency=mlx_gate_concurrency, digest_llm_timeout_s=digest_llm_timeout_s, diff --git a/app/main.py b/app/main.py index cf0f2c7..fae44b1 100644 --- a/app/main.py +++ b/app/main.py @@ -9,6 +9,7 @@ from sqlalchemy import func, select, text from api.audio import router as audio_router from api.internal_study import router as internal_study_router from api.internal_worker import router as internal_worker_router +from api.published import router as published_router from api.auth import router as auth_router from api.briefing import router as briefing_router from api.config import router as config_router @@ -236,6 +237,7 @@ app.include_router(briefing_router, prefix="/api/briefing", tags=["briefing"]) app.include_router(audio_router, prefix="/api/audio", tags=["audio"]) app.include_router(internal_study_router, prefix="/internal/study", tags=["internal-study"]) app.include_router(internal_worker_router, prefix="/internal/worker", tags=["internal-worker"]) +app.include_router(published_router, prefix="/published", tags=["published"]) app.include_router(video_router, prefix="/api/video", tags=["video"]) app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"]) app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])