feat(publish): P0-2 발행 read API /published/feed (study→viewer pull-sync)

뷰어가 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>
This commit is contained in:
hyungi
2026-06-24 14:25:56 +09:00
parent 864928809e
commit 83c28db572
3 changed files with 120 additions and 0 deletions
+113
View File
@@ -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,
)
+5
View File
@@ -191,6 +191,9 @@ class Settings(BaseModel):
# internal endpoint Bearer token (Mac mini derived-worker 호출용) # internal endpoint Bearer token (Mac mini derived-worker 호출용)
internal_worker_token: str = "" internal_worker_token: str = ""
# 뷰어↔DS 발행 채널 Bearer token (publish read API P0-2 + ingest P2). Mac mini 토큰과 분리(폭발반경 격리).
viewer_sync_token: str = ""
def load_settings() -> Settings: def load_settings() -> Settings:
"""config.yaml + 환경변수에서 설정 로딩""" """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_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") study_publish_enabled = os.getenv("STUDY_PUBLISH_ENABLED", "false").lower() in ("1", "true", "yes")
internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "") internal_worker_token = os.getenv("INTERNAL_WORKER_TOKEN", "")
viewer_sync_token = os.getenv("VIEWER_SYNC_TOKEN", "")
jwt_secret = os.getenv("JWT_SECRET", "") jwt_secret = os.getenv("JWT_SECRET", "")
totp_secret = os.getenv("TOTP_SECRET", "") totp_secret = os.getenv("TOTP_SECRET", "")
eval_runner_token = os.getenv("EVAL_RUNNER_TOKEN", "") 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_card_extract_enabled=study_card_extract_enabled,
study_publish_enabled=study_publish_enabled, study_publish_enabled=study_publish_enabled,
internal_worker_token=internal_worker_token, internal_worker_token=internal_worker_token,
viewer_sync_token=viewer_sync_token,
pipeline_held_stages=pipeline_held_stages, pipeline_held_stages=pipeline_held_stages,
mlx_gate_concurrency=mlx_gate_concurrency, mlx_gate_concurrency=mlx_gate_concurrency,
digest_llm_timeout_s=digest_llm_timeout_s, digest_llm_timeout_s=digest_llm_timeout_s,
+2
View File
@@ -9,6 +9,7 @@ from sqlalchemy import func, select, text
from api.audio import router as audio_router from api.audio import router as audio_router
from api.internal_study import router as internal_study_router from api.internal_study import router as internal_study_router
from api.internal_worker import router as internal_worker_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.auth import router as auth_router
from api.briefing import router as briefing_router from api.briefing import router as briefing_router
from api.config import router as config_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(audio_router, prefix="/api/audio", tags=["audio"])
app.include_router(internal_study_router, prefix="/internal/study", tags=["internal-study"]) 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(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(video_router, prefix="/api/video", tags=["video"])
app.include_router(study_sessions_router, prefix="/api/study-sessions", tags=["study-sessions"]) 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"]) app.include_router(study_topics_router, prefix="/api/study-topics", tags=["study-topics"])