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:
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user