You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
242 lines
8.9 KiB
Python
242 lines
8.9 KiB
Python
# backend/app/connectors/base.py
|
|
# 도메인별 커넥터 추상 인터페이스. mock=시드 기반, real=외부 제공자(phase-13).
|
|
import hashlib
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import Iterable
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from typing import Any, Optional
|
|
|
|
from sqlmodel import Session, select
|
|
|
|
from ..models import ConnectorAccount, ConnectorSyncState, ConnState, ExternalLink
|
|
|
|
|
|
class CalendarConnector(ABC):
|
|
"""calendar 도메인 읽기-전용 커넥터(phase-8). mock=시드(cal-data.js), real=Google Calendar.
|
|
페이지 읽기는 이 인터페이스만 의존. (phase-13 의 sync 프레임워크와 공존)."""
|
|
|
|
@abstractmethod
|
|
def list_events(self, session, day: Optional[int] = None) -> list[dict]: ...
|
|
|
|
@abstractmethod
|
|
def get_meeting(self, session, meeting_id: str) -> Optional[dict]: ...
|
|
|
|
@abstractmethod
|
|
def write_event(self, session, payload: dict) -> dict: ...
|
|
|
|
|
|
# ============================================================================
|
|
# phase-13: 통합 sync 프레임워크 — fetch / normalize / write / sync (인터페이스 불변)
|
|
# 위 읽기-전용 커넥터와 공존한다. 이쪽은 "수집(ingestion)" 계약.
|
|
# ============================================================================
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
|
|
@dataclass
|
|
class RawRecord:
|
|
"""제공자에서 fetch 한 1건의 원시 레코드(provider 페이로드)."""
|
|
|
|
external_id: str
|
|
payload: dict
|
|
etag: str = ""
|
|
external_updated_at: Optional[datetime] = None
|
|
|
|
|
|
@dataclass
|
|
class NormalizedRecord:
|
|
"""normalize() 산출물 — 내부 모델로 write 가능한 정규화 dict."""
|
|
|
|
entity_type: str # "email" | "event" | "finance_tx" | ...
|
|
external_id: str
|
|
fields: dict[str, Any] = field(default_factory=dict)
|
|
etag: str = ""
|
|
external_updated_at: Optional[datetime] = None
|
|
|
|
|
|
@dataclass
|
|
class SyncResult:
|
|
domain: str
|
|
account_id: str
|
|
seen: int = 0
|
|
upserted: int = 0
|
|
skipped: int = 0
|
|
errors: int = 0
|
|
events_published: list[str] = field(default_factory=list)
|
|
detail: str = ""
|
|
|
|
|
|
class BaseConnector(ABC):
|
|
"""모든 도메인 커넥터의 공통 계약. Mock/Real 이 동일하게 구현한다(인터페이스 불변)."""
|
|
|
|
domain: str = "base"
|
|
entity_type: str = "record"
|
|
|
|
def __init__(self, account: ConnectorAccount):
|
|
self.account = account
|
|
|
|
# ── (1) fetch: 제공자에서 원시 레코드를 가져온다(증분 since/cursor) ──
|
|
@abstractmethod
|
|
def fetch(self, session: Session, *, full: bool = False) -> Iterable[RawRecord]: ...
|
|
|
|
# ── (2) normalize: provider 페이로드 → 내부 모델 dict ──
|
|
@abstractmethod
|
|
def normalize(self, raw: RawRecord) -> NormalizedRecord: ...
|
|
|
|
# ── (3) write: 정규화 레코드를 내부 테이블에 upsert(external_link 멱등) ──
|
|
@abstractmethod
|
|
def write(self, session: Session, norm: NormalizedRecord) -> tuple[str, bool]:
|
|
"""return (entity_id, created) — created=False 면 update/skip."""
|
|
...
|
|
|
|
# ── 도메인이 발행할 연합 이벤트 타입(없으면 None) ──
|
|
def event_for(self, norm: NormalizedRecord, entity_id: str) -> Optional[str]:
|
|
return None
|
|
|
|
# ── (4) sync: fetch→normalize→write 오케스트레이션 + event_bus 발행 ──
|
|
def sync(self, session: Session, *, full: bool = False) -> SyncResult:
|
|
from ..automation.event_bus import bus # phase-7 정본 (지연 import)
|
|
|
|
res = SyncResult(domain=self.domain, account_id=self.account.id)
|
|
self._set_state(session, ConnState.syncing)
|
|
try:
|
|
for raw in self.fetch(session, full=full):
|
|
res.seen += 1
|
|
if self._is_duplicate(session, raw): # etag 동일 → skip(멱등)
|
|
res.skipped += 1
|
|
continue
|
|
norm = self.normalize(raw)
|
|
# provider 가 스팸으로 분류한 메일은 저장하지 않는다(유저: 아예 안 가져옴).
|
|
# folder 없는 도메인(캘린더 등)은 .get→None 이라 영향 없음.
|
|
if norm.fields.get("folder") == "spam":
|
|
res.skipped += 1
|
|
continue
|
|
entity_id, created = self.write(session, norm)
|
|
self._upsert_link(session, raw, norm, entity_id)
|
|
if created:
|
|
res.upserted += 1
|
|
evt = self.event_for(norm, entity_id)
|
|
if evt:
|
|
bus.publish(
|
|
evt,
|
|
{
|
|
"account_id": self.account.id,
|
|
"entity_type": norm.entity_type,
|
|
"entity_id": entity_id,
|
|
},
|
|
)
|
|
res.events_published.append(evt)
|
|
else:
|
|
res.skipped += 1
|
|
session.commit()
|
|
self._finish_ok(session, res)
|
|
bus.publish(
|
|
"connector.synced",
|
|
{
|
|
"account_id": self.account.id,
|
|
"upserted": res.upserted,
|
|
"skipped": res.skipped,
|
|
"errors": res.errors,
|
|
},
|
|
)
|
|
except Exception as e: # 부분 실패도 서버 죽지 않음(오프라인 폴백 철학)
|
|
session.rollback()
|
|
res.errors += 1
|
|
res.detail = f"{type(e).__name__}: {e}"
|
|
self._finish_error(session, res.detail)
|
|
bus.publish(
|
|
"connector.error",
|
|
{"account_id": self.account.id, "detail": res.detail, "state": "error"},
|
|
)
|
|
return res
|
|
|
|
# ── 공통: 중복/멱등 ──
|
|
def _link_for(self, session: Session, external_id: str) -> Optional[ExternalLink]:
|
|
return session.exec(
|
|
select(ExternalLink).where(
|
|
ExternalLink.account_id == self.account.id,
|
|
ExternalLink.external_id == external_id,
|
|
)
|
|
).first()
|
|
|
|
def _is_duplicate(self, session: Session, raw: RawRecord) -> bool:
|
|
link = self._link_for(session, raw.external_id)
|
|
return bool(link and raw.etag and link.etag == raw.etag)
|
|
|
|
def _upsert_link(self, session, raw, norm, entity_id):
|
|
link = self._link_for(session, raw.external_id)
|
|
if link:
|
|
link.etag = norm.etag
|
|
link.entity_id = entity_id
|
|
link.external_updated_at = norm.external_updated_at
|
|
session.add(link)
|
|
else:
|
|
xlid = (
|
|
"xl-"
|
|
+ hashlib.sha1(f"{self.account.id}:{raw.external_id}".encode()).hexdigest()[:12]
|
|
)
|
|
session.add(
|
|
ExternalLink(
|
|
id=xlid,
|
|
account_id=self.account.id,
|
|
external_id=raw.external_id,
|
|
entity_type=norm.entity_type,
|
|
entity_id=entity_id,
|
|
etag=norm.etag,
|
|
external_updated_at=norm.external_updated_at,
|
|
)
|
|
)
|
|
|
|
# ── 상태/커서/로그 ──
|
|
def _state_row(self, session) -> ConnectorSyncState:
|
|
st = session.get(ConnectorSyncState, f"cs-{self.account.id}")
|
|
if not st:
|
|
st = ConnectorSyncState(id=f"cs-{self.account.id}", account_id=self.account.id)
|
|
session.add(st)
|
|
return st
|
|
|
|
def _set_state(self, session, state: ConnState):
|
|
self.account.state = state
|
|
self.account.updated_at = _now()
|
|
session.add(self.account)
|
|
session.commit()
|
|
|
|
def _finish_ok(self, session, res: SyncResult):
|
|
self.account.state = ConnState.connected
|
|
self.account.last_synced_at = _now()
|
|
if res.upserted or res.seen:
|
|
self.account.last_label = "방금 동기화"
|
|
self.account.error_detail = ""
|
|
st = self._state_row(session)
|
|
st.items_seen += res.seen
|
|
st.items_upserted += res.upserted
|
|
st.last_delta_sync_at = _now()
|
|
session.add_all([self.account, st])
|
|
session.commit()
|
|
self._log(session, "sync", f"upserted={res.upserted} skipped={res.skipped}", res.upserted)
|
|
|
|
def _finish_error(self, session, detail: str):
|
|
self.account.state = ConnState.error
|
|
self.account.error_detail = detail
|
|
self.account.last_label = "동기화 실패 · 다시 시도"
|
|
session.add(self.account)
|
|
session.commit()
|
|
self._log(session, "error", detail)
|
|
|
|
def _log(self, session, action: str, detail: str = "", items: int = 0):
|
|
import uuid
|
|
|
|
from ..models import ConnectorAccountLog
|
|
|
|
session.add(
|
|
ConnectorAccountLog(
|
|
id="cl-" + uuid.uuid4().hex[:8],
|
|
account_id=self.account.id,
|
|
action=action,
|
|
detail=detail,
|
|
items=items,
|
|
)
|
|
)
|
|
session.commit()
|