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.

85 KiB

Phase 13 — 외부 연동 & 데이터 커넥터 (mock → real)

한 줄 요약: phase 7~12에서 mock-first 로 만든 모든 페이지(라이프·메일·일정·알림·리서치·여행·결재함·자동화)를, 코드 한 줄 안 바꾸고 실제 외부 데이터로 전환한다. 커넥터 추상화(backend/app/connectors/) 뒤에 MockConnector 대신 RealConnector 를 꽂으면(CONNECTOR_<DOMAIN>=real), 동일 인터페이스(fetch / sync / normalize / write)를 통해 실데이터가 흘러들고 연합 이벤트(mail.received, calendar.updated, …)가 실제로 발생해 자동화·작업·알림이 진짜 데이터로 작동한다.

이 문서는 포스트-MVP 세트의 일부입니다 — 먼저 dev/overview.mddev/post-mvp-overview.md 를 읽으세요.

선행(필수): phase-2-backend.md(모델/스키마/라우터/시드/Provider 추상화 규약 — 모든 신규 테이블·엔드포인트는 이 패턴을 따른다), phase-7-approvals-automation.md(자율성 코어·event_bus·자동화 엔진·결재함), phase-8-calendar-meetings.md, phase-9-mail-notifications.md, phase-10-research-travel.md, phase-11-life-care.md, phase-12-daily-narrative.md(모두 mock-first 로 커넥터를 통해 데이터를 읽도록 구현되어 있어야 한다).

후속: phase-14-proactive-agent.md(능동형 에이전트 + 멀티모달 캡처 — 본 phase 의 RealConnector 가 공급하는 실데이터 이벤트 위에서 동작).


0. 목차

  1. 개요 & 목표
  2. 선행 조건 / 산출물
  3. 상세 구현 — 커넥터 프레임워크
  4. 데이터/타입/API 계약
  5. 디자인 충실도 노트
  6. 상태 처리 & 엣지 케이스
  7. 연합 이벤트(발행/구독)
  8. 테스팅 & 검증
  9. 완료 기준 (Definition of Done)
  10. 다음 단계

1. 개요 & 목표

1.1 이 phase가 끝나면 무엇이 동작하는가

phase 7~12 의 페이지들은 이미 동작한다 — 단, 시드(life-data.js/mail-data.js/cal-data.js 등에서 이식한 목업) 위에서. Phase 13 이 끝나면:

  • backend/app/connectors/ 에 도메인별 base 인터페이스(fetch / sync / normalize / write) + 각 도메인의 MockConnector(시드 기반) + RealConnector(실제 제공자) 가 존재한다.
  • 환경변수 한 줄(CONNECTOR_MAIL=real, CONNECTOR_CALENDAR=real, …)만 바꾸면 페이지/라우터 코드는 그대로 두고 데이터 소스가 mock→real 로 전환된다(인터페이스 불변).
  • 6개 도메인 커넥터가 준비된다: mail(Gmail API / Google Workspace / HEY), calendar(Google Calendar API), chat(메신저), finance(카드/뱅크 아그리게이터 또는 수동 CSV 임포트), health(Apple Health/HealthKit export / Google Fit), knowledge(Notion API / Readwise / 웹 클리퍼).
  • 제공자별 OAuth2 플로우(또는 토큰/CSV 임포트 대안)와 암호화된 토큰 저장(connector_account.token_enc)·만료/갱신이 구현된다.
  • 증분 sync(델타 토큰/since 커서) · 정규화(provider 페이로드 → 내부 모델) · 충돌/중복 처리(external_id UNIQUE upsert) · 레이트리밋(지수 백오프 + per-connector 윈도우)가 동작한다.
  • 실데이터가 들어오면 event_bus(phase-7 정본)에 mail.received / calendar.updated실제 이벤트가 발행되어, 자동화 엔진(evaluator)·작업 실체화·알림 트리아지가 실데이터로 동작한다.
  • 로컬 우선: Ollama 로컬 LLM 철학과 정합 — 가능한 데이터는 로컬/수동 임포트(CSV·HealthKit export·.ics 파일·Markdown 노트)로도 들일 수 있고, 클라우드 OAuth 연동은 선택이다.
  • 라이프 페이지의 연결된 데이터 소스 카드(life-data.js: sourceson/last — "Apple Health · 방금 동기화", "우리카드 · 오늘 09:12", "Google Fit · 연결 안 됨")가 mock 의 정적 값이 아니라 실제 커넥터 상태(connected/disconnected/syncing/error/token_expired)를 비춘다.

1.2 핵심 설계 원칙 — "인터페이스 불변, 구현만 교체"

                                                      ┌─ MockConnector  (시드 기반, 기본값)
페이지/라우터 ── ConnectorRegistry.get(domain) ──→ BaseConnector(추상) ─┤
   (코드 불변)        (CONNECTOR_<DOMAIN> 로 선택)                       └─ RealConnector  (OAuth/실제 API)
                                                                              │ normalize()
                                                                              ▼
                                                                       내부 모델(email/event/finance_tx/...)
                                                                              │ upsert(external_id UNIQUE)
                                                                              ▼
                                                                       event_bus.publish(mail.received 등)
                                                                              │
                                                                              ▼
                                                                       자동화/작업/알림 (실데이터로 동작)

CONTRACT 의 횡단 아키텍처 명명을 그대로 사용한다: 커넥터는 backend/app/connectors/, 도메인은 mail/calendar/chat/finance/health/knowledge, 설정은 CONNECTOR_<DOMAIN>=mock|real(기본 mock), 이벤트는 event_bus(phase-7), 임베딩/지식은 backend/app/rag/(phase-10) 와 공유한다.

모델 비종속 = 제공자 비종속의 확장. phase-2 가 LLM 을 LLMProvider 로 추상화해 "모델 강제 금지 + env 주입 + 폴백" 한 것과 동일한 정신으로, Phase 13 은 외부 데이터 제공자를 BaseConnector 로 추상화해 "제공자 강제 금지 + env 주입 + mock 폴백" 한다. Gmail/Google Calendar/Apple Health/Notion 등 구체 제공자는 목표 예시이며 교체 가능(CONTRACT 가정).


2. 선행 조건 / 산출물

2.1 선행 조건 (의존 phase)

의존 무엇을 가져오는가
phase-2-backend.md SQLModel/Pydantic/Alembic 패턴, 라우터 prefix 규약(라우터 내부 prefix 없음 → main.pyinclude_router(prefix="/api")), config.py Settings(env 주입), TEXT PK 규약, run_seed(session=None, reset=True), get_provider()(LLM 추상화 — 폴백 패턴의 원형).
phase-7-approvals-automation.md event_bus(내부 이벤트 버스, 발행/구독 정본), evaluator(이벤트→규칙 매칭), approval 모델 + autonomy_setting(low+mixed 이상=자동실행, high=승인대기), backend/app/worker/(스케줄러 — 동기화 작업을 여기에 등록).
phase-8-calendar-meetings.md calendar/event/focus_block/meeting 모델. RealConnector(calendar) 의 정규화 타깃.
phase-9-mail-notifications.md mail_account/email/notification 모델 + 메일→작업/일정 추출(email.ai.tasks/events), 알림 트리아지. RealConnector(mail) 의 정규화 타깃.
phase-10-research-travel.md backend/app/rag/(ingest→chunk→embed→vector), knowledge_qa. knowledge 커넥터가 ingest 로 흘려보낸다.
phase-11-life-care.md connector_source(health/finance/knowledge on/last), health/finance/knowledge_item 모델. 라이프 페이지 connector 카드.
phase-12-daily-narrative.md 하루 마감 집계 — sync 결과(아낀 시간·자동 처리 건수)가 narrative 에 합산.

이 phase 가 mock 을 끄지 않는다. mock 은 데모/오프라인/CI 의 결정적 기본값으로 영구 유지된다. real 은 사용자가 명시적으로 켤 때만(CONNECTOR_<DOMAIN>=real + 자격증명) 활성화. 단일 데모 사용자(지우)는 유지하고 멀티유저/per-user 토큰 스코프는 phase-15-production.md 에서 도입한다(본 phase 의 connector_account.user_id"jiwoo" 고정 시드).

2.2 산출물 (Deliverables)

backend/
├─ app/
│  ├─ config.py                      (+) CONNECTOR_<DOMAIN>, OAuth/자격증명 env, ARI_SECRET_KEY
│  ├─ models.py                      (+) connector_account, connector_sync_state, external_link, oauth_state, connector_account_log
│  ├─ schemas.py                     (+) ConnectorStatusOut, OAuthStartOut, SyncResultOut, ImportResultOut, ...
│  ├─ crypto.py                      (+) Fernet 기반 토큰 암복호화 (ARI_SECRET_KEY 파생)
│  ├─ connectors/
│  │  ├─ __init__.py
│  │  ├─ base.py                     BaseConnector(추상): fetch/sync/normalize/write + 공통 upsert/레이트리밋
│  │  ├─ registry.py                 ConnectorRegistry.get(domain) — CONNECTOR_<DOMAIN> 로 mock|real 선택 + 캐시
│  │  ├─ ratelimit.py               토큰 버킷 + 지수 백오프(429/Retry-After)
│  │  ├─ oauth.py                    OAuth2 Authorization Code(+PKCE) 공통 헬퍼 (start/callback/refresh)
│  │  ├─ mail/        base.py mock.py real_gmail.py real_imap.py normalize.py
│  │  ├─ calendar/    base.py mock.py real_google.py ics_import.py normalize.py
│  │  ├─ chat/        base.py mock.py real_webhook.py normalize.py
│  │  ├─ finance/     base.py mock.py csv_import.py real_aggregator.py normalize.py
│  │  ├─ health/      base.py mock.py healthkit_import.py real_fit.py normalize.py
│  │  └─ knowledge/   base.py mock.py real_notion.py real_readwise.py clipper.py normalize.py
│  ├─ routers/
│  │  └─ connectors.py               GET/POST /api/connectors* (상태/연결/해제/동기화/임포트/OAuth 콜백)
│  └─ worker/
│     └─ sync_jobs.py                (+) 주기 sync 잡 등록(APScheduler) + 수동 트리거 진입점
├─ migrations/versions/xxxx_connectors.py
└─ tests/
   ├─ test_connector_contract.py     모든 커넥터가 BaseConnector 계약 충족(인터페이스 계약 테스트)
   ├─ test_connector_swap.py         CONNECTOR_<DOMAIN> 스왑(mock↔real) — 라우터 응답 동일 shape
   ├─ test_oauth_mock.py             OAuth 플로우 모킹(respx) — start→callback→token 저장(암호화)
   ├─ test_sync_idempotent.py        동기화 멱등성(같은 페이로드 2회 → 중복 0)
   ├─ test_normalize_golden.py       정규화 골든(provider 페이로드 → 내부 모델 고정 매핑)
   ├─ test_crypto.py                 토큰 암복호화 왕복
   └─ test_federation_real.py        real(가짜) sync → event_bus 에 mail.received/calendar.updated 발행
frontend/
├─ lib/types.ts                      (+) ConnectorStatus, OAuthStart, SyncResult, ImportResult
├─ components/connectors/
│  ├─ ConnectorCard.tsx              라이프 connector 카드 재사용 (on/last/상태)
│  ├─ ConnectStateBadge.tsx          연결/해제/동기화 중/오류/권한 만료 배지
│  └─ ImportDropzone.tsx             CSV/.ics/HealthKit export 수동 임포트
└─ playwright/connectors.spec.ts     연결→동기화→실데이터 반영 E2E (real=fake provider)

새 의존성(backend): cryptography(Fernet 토큰 암호화), respx(httpx OAuth 모킹 — dev). pip/uv 추가는 §8.1 에 명령 포함. APScheduler 는 phase-7 worker 에서 이미 도입했다고 가정(없으면 phase-7 에서 추가).


3. 상세 구현 — 커넥터 프레임워크

3.0 설정 — config.py 확장

phase-2 Settings 에 커넥터/OAuth/암호화 키 필드를 더한다. pydantic-settings 가 필드명을 대문자 env 로 자동 매핑(connector_mailCONNECTOR_MAIL).

# backend/app/config.py  (phase-2 Settings 에 추가)
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    # ── (phase-2 에서 상속) ──
    database_url: str = "sqlite:///./ari.db"
    ollama_host: str = "http://localhost:11434"
    ollama_model: str = "llama3.1"
    llm_provider: str = "auto"
    llm_timeout: float = 20.0
    risk_today: int = 8
    frontend_origin: str = "http://localhost:3000"
    ari_allow_test_reset: bool = False

    # ── (phase-13 신규) 도메인별 커넥터 선택. 기본 mock. ──
    connector_mail: str = "mock"          # mock | real
    connector_calendar: str = "mock"
    connector_chat: str = "mock"
    connector_finance: str = "mock"       # mock | csv | real
    connector_health: str = "mock"        # mock | healthkit | real
    connector_knowledge: str = "mock"

    # ── 토큰 암호화 키(필수 when real). Fernet 키 파생용. ──
    ari_secret_key: str = "dev-insecure-change-me-please-32bytes!"

    # ── OAuth2 자격증명(제공자별). 비어 있으면 해당 RealConnector 비활성. ──
    google_client_id: str = ""            # Gmail + Google Calendar 공용
    google_client_secret: str = ""
    google_redirect_uri: str = "http://localhost:8000/api/connectors/oauth/callback"
    notion_client_id: str = ""
    notion_client_secret: str = ""
    readwise_token: str = ""              # Readwise 는 단순 토큰(OAuth 아님)

    # ── 동기화 정책 ──
    sync_interval_minutes: int = 15       # worker 주기 sync
    sync_page_size: int = 50
    connector_http_timeout: float = 20.0


@lru_cache
def get_settings() -> Settings:
    return Settings()

.env 예시(루트 또는 backend/.env) — 로컬 우선이라 전부 mock 이 기본, real 은 선택:

# (phase-0/2 에서 상속)
DATABASE_URL=sqlite:///./ari.db
OLLAMA_HOST=http://localhost:11434
OLLAMA_MODEL=qwen2.5:7b
LLM_PROVIDER=auto
FRONTEND_ORIGIN=http://localhost:3000

# ── 커넥터(phase-13) — 기본 전부 mock. 켤 도메인만 바꾼다. ──
CONNECTOR_MAIL=mock           # mock | real
CONNECTOR_CALENDAR=mock       # mock | real (real=Google Calendar)
CONNECTOR_CHAT=mock
CONNECTOR_FINANCE=mock        # mock | csv (수동 CSV 임포트) | real
CONNECTOR_HEALTH=mock         # mock | healthkit (export 임포트) | real (Google Fit)
CONNECTOR_KNOWLEDGE=mock      # mock | real (Notion/Readwise/클리퍼)

# ── 암호화 키(real 켤 때 필수, 32+ 바이트 권장) ──
ARI_SECRET_KEY=please-generate-a-long-random-secret-key

# ── OAuth (real 켤 때만) ──
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/api/connectors/oauth/callback
NOTION_CLIENT_ID=
NOTION_CLIENT_SECRET=
READWISE_TOKEN=

3.1 데이터 모델 — models.py 추가 (phase-2 패턴)

phase-2 규약 그대로: 모든 PK 는 TEXT(str), enum 은 str, Enum, now() 헬퍼 재사용. 신규 5개 테이블.

# backend/app/models.py  (phase-2 에 추가)
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlmodel import SQLModel, Field
# from .models import now  (phase-2 정의 재사용)


class ConnectorDomain(str, Enum):
    mail = "mail"
    calendar = "calendar"
    chat = "chat"
    finance = "finance"
    health = "health"
    knowledge = "knowledge"


class ConnectorMode(str, Enum):
    mock = "mock"
    csv = "csv"
    healthkit = "healthkit"
    ics = "ics"
    real = "real"


class ConnState(str, Enum):
    connected = "connected"       # 정상 연결 (라이프 카드 on:true)
    disconnected = "disconnected" # 미연결 (life-data.js: "연결 안 됨")
    syncing = "syncing"           # 동기화 중
    error = "error"               # 마지막 sync 실패
    token_expired = "token_expired"  # 권한 만료 → 재인증 필요


class ConnectorAccount(SQLModel, table=True):
    """한 도메인의 한 제공자 계정. 예: mail/work(jiwoo@lumi.co), finance/woori."""
    __tablename__ = "connector_account"
    id: str = Field(primary_key=True)              # TEXT PK 예: "ca-mail-work"
    user_id: str = Field(default="jiwoo", foreign_key="person.id")  # 단일 데모 사용자(phase-15 멀티유저)
    domain: ConnectorDomain
    mode: ConnectorMode = ConnectorMode.mock
    provider: str = ""                             # "gmail" | "google_calendar" | "notion" | "woori" | "apple_health" ...
    external_account_id: str = ""                  # 제공자 계정 식별자(이메일 주소 등)
    name: str = ""                                 # 표시명 예: "회사" / "우리카드"
    kind: str = ""                                 # life-data.js 의 kind 예: "Lumi Workspace" / "신용/체크"
    tone: str = "ink"                              # tone 집합: blue|violet|coral|green|amber|ink|faint
    state: ConnState = ConnState.disconnected
    last_synced_at: Optional[datetime] = None
    last_label: str = "연결 안 됨"                  # 라이프 카드 last 문구 (예: "방금 동기화" / "오늘 09:12")
    token_enc: str = ""                            # 암호화된 토큰 JSON (crypto.py). 평문 저장 금지.
    scopes: str = ""                               # 콤마구분 OAuth 스코프
    error_detail: str = ""
    created_at: datetime = Field(default_factory=now)
    updated_at: datetime = Field(default_factory=now)


class ConnectorSyncState(SQLModel, table=True):
    """증분 sync 커서. 제공자별 델타 토큰 또는 since 타임스탬프."""
    __tablename__ = "connector_sync_state"
    id: str = Field(primary_key=True)              # "cs-<account_id>"
    account_id: str = Field(foreign_key="connector_account.id")
    cursor: str = ""                               # Gmail historyId / Google syncToken / ISO since
    page_token: str = ""                           # 진행 중 페이지네이션 토큰(중단 복구용)
    last_full_sync_at: Optional[datetime] = None
    last_delta_sync_at: Optional[datetime] = None
    items_seen: int = 0
    items_upserted: int = 0


class ExternalLink(SQLModel, table=True):
    """외부 제공자 객체 ↔ 내부 엔티티 매핑(중복/충돌 처리의 핵심).
       (account_id, external_id) UNIQUE 로 멱등 upsert."""
    __tablename__ = "external_link"
    id: str = Field(primary_key=True)              # "xl-<hash>"
    account_id: str = Field(foreign_key="connector_account.id", index=True)
    external_id: str = Field(index=True)           # 제공자측 고유 id (Gmail message id 등)
    entity_type: str                               # "email" | "event" | "finance_tx" | "health_sample" | "knowledge_item" | "chat_message"
    entity_id: str                                 # 내부 엔티티 PK (예: email.id)
    etag: str = ""                                 # 변경 감지용(있으면 비교 후 skip)
    external_updated_at: Optional[datetime] = None
    created_at: datetime = Field(default_factory=now)


class OAuthState(SQLModel, table=True):
    """OAuth Authorization Code 플로우의 CSRF state + PKCE verifier 임시 저장."""
    __tablename__ = "oauth_state"
    id: str = Field(primary_key=True)              # state nonce 자체를 PK 로
    domain: ConnectorDomain
    provider: str
    code_verifier: str = ""                        # PKCE
    redirect_after: str = "/life"                  # 콜백 후 프론트 복귀 경로
    created_at: datetime = Field(default_factory=now)


class ConnectorAccountLog(SQLModel, table=True):
    """연결/해제/동기화/오류 활동 로그(결재함 activity log 와 동일 톤)."""
    __tablename__ = "connector_account_log"
    id: str = Field(primary_key=True)              # "cl1", ...
    account_id: str = Field(foreign_key="connector_account.id")
    action: str                                    # "connected" | "disconnected" | "sync" | "error" | "token_refreshed" | "import"
    detail: str = ""
    items: int = 0
    created_at: datetime = Field(default_factory=now)

external_link(account_id, external_id)복합 UNIQUE 인덱스로 만든다(Alembic 에서 op.create_index(..., unique=True)). 이것이 "같은 메일/일정을 두 번 받아도 한 번만 저장"(멱등성)을 보장하는 단일 지점이다.

Alembic 마이그레이션

cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
uv run alembic revision --autogenerate -m "phase13 connectors"
# 생성 파일에 external_link 복합 UNIQUE 인덱스 수동 보강:
#   op.create_index("ux_extlink_acct_ext", "external_link", ["account_id", "external_id"], unique=True)
uv run alembic upgrade head
# 왕복:
uv run alembic downgrade -1 && uv run alembic upgrade head

시드 — mock 계정(라이프/메일 카드 값 그대로)

run_seed() 의 하위 헬퍼 _seed_connectors(session) 를 추가한다(phase-2 의 _seed_dashboard 와 동일 패턴 — run_seed 내부에서만 호출). 값은 원본 life-data.js: sourcesmail-data.js: accounts 그대로 이식한다.

# backend/app/seed.py  (run_seed 안에서 _seed_connectors(s) 호출)
# (domain, id, provider, name, kind, tone, on, last)  ← life-data.js sources / mail-data.js accounts 그대로
CONNECTORS = [
    # mail (mail-data.js accounts) — 회사/개인/사이드 3계정
    ("mail", "ca-mail-work",     "google_workspace", "회사",  "Lumi Workspace", "coral",  True, "방금 동기화"),
    ("mail", "ca-mail-personal", "gmail",            "개인",  "Google",         "blue",   True, "방금 동기화"),
    ("mail", "ca-mail-side",     "hey",              "사이드","HEY",            "violet", True, "방금 동기화"),
    # calendar (cal-data.js cals 는 카테고리지만 연동 단위는 google_calendar 1계정으로 묶음)
    ("calendar", "ca-cal-google", "google_calendar", "Google 캘린더", "캘린더", "blue", True, "방금 동기화"),
    # health (life-data.js sources.health)
    ("health", "ca-health-apple", "apple_health", "Apple Health", "건강 앱",  "coral",  True,  "방금 동기화"),
    ("health", "ca-health-watch", "apple_watch",  "Apple Watch",  "웨어러블", "violet", True,  "1분 전"),
    ("health", "ca-health-fit",   "google_fit",   "Google Fit",   "활동 추적","green",  False, "연결 안 됨"),
    # finance (life-data.js sources.finance)
    ("finance", "ca-fin-woori", "woori",      "우리카드",   "신용/체크", "blue",  True,  "오늘 09:12"),
    ("finance", "ca-fin-toss",  "toss",       "토스",       "계좌 통합", "coral", True,  "오늘 08:40"),
    ("finance", "ca-fin-kakao", "kakaobank",  "카카오뱅크", "입출금",    "amber", True,  "어제"),
    ("finance", "ca-fin-kb",    "kb_sec",     "KB증권",     "투자",      "green", False, "연결 안 됨"),
    # knowledge (life-data.js sources.knowledge)
    ("knowledge", "ca-kn-notion",  "notion",   "Notion",    "메모·문서",     "violet", True, "오늘"),
    ("knowledge", "ca-kn-web",     "clipper",  "웹 클리퍼", "아티클 스크랩", "blue",   True, "2시간 전"),
    ("knowledge", "ca-kn-readwise","readwise", "Readwise",  "하이라이트",    "coral",  True, "어제"),
]

def _seed_connectors(s):
    from .models import ConnectorAccount, ConnState, ConnectorMode
    for domain, cid, provider, name, kind, tone, on, last in CONNECTORS:
        s.add(ConnectorAccount(
            id=cid, user_id="jiwoo", domain=domain, mode=ConnectorMode.mock,
            provider=provider, name=name, kind=kind, tone=tone,
            external_account_id=name, last_label=last,
            state=ConnState.connected if on else ConnState.disconnected,
        ))

시드는 mock 모드 의 계정이다. on:false 인 Google Fit·KB증권은 disconnected 로 — 원본 life-data.js"연결 안 됨" 문구(last)를 last_label 에 그대로 보존한다. 이메일 계정은 mail-data.jsid(work/personal/side)·name(회사/개인/사이드)·kind(Lumi Workspace/Google/HEY)·tone(coral/blue/violet) 을 1바이트도 안 바꾸고 옮긴다.


3.2 토큰 암호화 — crypto.py

평문 토큰은 절대 DB 에 저장하지 않는다. ARI_SECRET_KEY 에서 Fernet 키를 파생해 토큰 JSON 을 대칭 암호화한다.

# backend/app/crypto.py
import base64
import hashlib
import json
from cryptography.fernet import Fernet, InvalidToken
from .config import get_settings


def _fernet() -> Fernet:
    # ARI_SECRET_KEY -> 32바이트 -> urlsafe base64 -> Fernet 키
    raw = get_settings().ari_secret_key.encode("utf-8")
    key = base64.urlsafe_b64encode(hashlib.sha256(raw).digest())
    return Fernet(key)


def encrypt_token(token_dict: dict) -> str:
    """{'access_token':..., 'refresh_token':..., 'expires_at':...} -> 암호문 str."""
    return _fernet().encrypt(json.dumps(token_dict).encode("utf-8")).decode("utf-8")


def decrypt_token(enc: str) -> dict:
    if not enc:
        return {}
    try:
        return json.loads(_fernet().decrypt(enc.encode("utf-8")).decode("utf-8"))
    except (InvalidToken, ValueError):
        return {}   # 키 회전/손상 → 빈 dict (호출측은 token_expired 처리)

운영(phase-15)에서는 ARI_SECRET_KEY 를 시크릿 매니저에서 주입하고 키 회전 정책을 둔다. 본 phase 는 env 주입 + 손상 시 안전 폴백({} → 재인증)만 보장.


3.3 Base 인터페이스 — connectors/base.py

CONTRACT 의 4개 메서드 fetch / sync / normalize / write 를 추상화한다. LLM LLMProvider(ABC) 패턴을 그대로 따른다.

# backend/app/connectors/base.py
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Iterable
from sqlmodel import Session, select
from ..models import ConnectorAccount, ConnectorSyncState, ExternalLink, ConnState
import hashlib


def _now():
    return datetime.now(timezone.utc)


@dataclass
class RawRecord:
    """제공자에서 fetch 한 1건의 원시 레코드(provider 페이로드)."""
    external_id: str
    payload: dict
    etag: str = ""
    external_updated_at: datetime | None = 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: datetime | None = 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."""
        ...

    # ── (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)
                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)  # 예: "mail.received"
                    if evt:
                        bus.publish(evt, {"account_id": self.account.id,
                                          "entity_type": norm.entity_type,
                                          "entity_id": entity_id})
                        res.events_published.append(evt)
            session.commit()
            self._finish_ok(session, res)
        except Exception as e:               # 부분 실패도 서버 죽지 않음(오프라인 폴백 철학)
            session.rollback()
            res.errors += 1
            res.detail = f"{type(e).__name__}: {e}"
            self._finish_error(session, res.detail)
        return res

    # ── 도메인이 발행할 연합 이벤트 타입(없으면 None) ──
    def event_for(self, norm: NormalizedRecord, entity_id: str) -> str | None:
        return None

    # ── 공통: 중복/멱등 ──
    def _is_duplicate(self, session: Session, raw: RawRecord) -> bool:
        link = session.exec(
            select(ExternalLink).where(ExternalLink.account_id == self.account.id,
                                       ExternalLink.external_id == raw.external_id)).first()
        return bool(link and raw.etag and link.etag == raw.etag)

    def _upsert_link(self, session, raw, norm, entity_id):
        link = session.exec(
            select(ExternalLink).where(ExternalLink.account_id == self.account.id,
                                       ExternalLink.external_id == raw.external_id)).first()
        if link:
            link.etag = norm.etag; link.entity_id = entity_id
            link.external_updated_at = norm.external_updated_at
        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()
        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
        session.add(self.account); session.commit()
        self._log(session, "error", detail)

    def _log(self, session, action: str, detail: str = "", items: int = 0):
        from ..models import ConnectorAccountLog
        import uuid
        session.add(ConnectorAccountLog(id="cl-" + uuid.uuid4().hex[:8],
                    account_id=self.account.id, action=action, detail=detail, items=items))
        session.commit()

write()(entity_id, created) 를 반환한다 — created=True 일 때만 이벤트를 발행해 재동기화로 같은 메일이 다시 알림을 만드는 일을 막는다. _is_duplicate 는 etag 동일 시 skip — 멱등성의 두 번째 방어선(첫 번째는 external_link UNIQUE).


3.4 레지스트리 — connectors/registry.py

CONNECTOR_<DOMAIN> 로 mock/real 을 고르는 단일 지점. 페이지/라우터는 항상 레지스트리를 통해서만 커넥터를 얻는다(코드 불변의 핵심).

# backend/app/connectors/registry.py
from __future__ import annotations
from sqlmodel import Session, select
from ..config import get_settings
from ..models import ConnectorAccount, ConnectorDomain
from .base import BaseConnector

# 도메인 × 모드 → 구현 클래스 (lazy import 로 의존성 격리)
def _impl(domain: str, mode: str):
    if domain == "mail":
        from .mail.mock import MockMailConnector
        from .mail.real_gmail import GmailConnector
        from .mail.real_imap import ImapConnector
        return {"mock": MockMailConnector, "real": GmailConnector, "imap": ImapConnector}.get(mode, MockMailConnector)
    if domain == "calendar":
        from .calendar.mock import MockCalendarConnector
        from .calendar.real_google import GoogleCalendarConnector
        from .calendar.ics_import import IcsConnector
        return {"mock": MockCalendarConnector, "real": GoogleCalendarConnector, "ics": IcsConnector}.get(mode, MockCalendarConnector)
    if domain == "finance":
        from .finance.mock import MockFinanceConnector
        from .finance.csv_import import CsvFinanceConnector
        from .finance.real_aggregator import AggregatorConnector
        return {"mock": MockFinanceConnector, "csv": CsvFinanceConnector, "real": AggregatorConnector}.get(mode, MockFinanceConnector)
    if domain == "health":
        from .health.mock import MockHealthConnector
        from .health.healthkit_import import HealthKitConnector
        from .health.real_fit import GoogleFitConnector
        return {"mock": MockHealthConnector, "healthkit": HealthKitConnector, "real": GoogleFitConnector}.get(mode, MockHealthConnector)
    if domain == "knowledge":
        from .knowledge.mock import MockKnowledgeConnector
        from .knowledge.real_notion import NotionConnector
        return {"mock": MockKnowledgeConnector, "real": NotionConnector}.get(mode, MockKnowledgeConnector)
    if domain == "chat":
        from .chat.mock import MockChatConnector
        from .chat.real_webhook import WebhookChatConnector
        return {"mock": MockChatConnector, "real": WebhookChatConnector}.get(mode, MockChatConnector)
    raise ValueError(f"unknown domain {domain}")


class ConnectorRegistry:
    @staticmethod
    def mode_for(domain: str) -> str:
        return getattr(get_settings(), f"connector_{domain}", "mock")

    @staticmethod
    def get(session: Session, account: ConnectorAccount) -> BaseConnector:
        """계정별 커넥터 인스턴스. 모드는 env(CONNECTOR_<DOMAIN>) > 계정.mode 순.
        단, mock 시드 계정은 env 와 무관하게 mock 으로(데모 결정성)."""
        env_mode = ConnectorRegistry.mode_for(account.domain)
        mode = env_mode if account.mode != "mock" or env_mode != "real" else "mock"
        # 정책: env=real 이면 real 계정만 real 로. 시드 mock 계정은 그대로 mock.
        mode = env_mode if account.mode.value != "mock" else (
            "mock" if env_mode == "real" else env_mode)
        Impl = _impl(account.domain, account.mode.value if account.mode.value != "mock" else env_mode)
        return Impl(account)

    @staticmethod
    def accounts(session: Session, domain: str | None = None) -> list[ConnectorAccount]:
        q = select(ConnectorAccount)
        if domain:
            q = q.where(ConnectorAccount.domain == domain)
        return session.exec(q.order_by(ConnectorAccount.id)).all()

모드 결정 정본: 계정 row 의 mode 가 우선(연결 시 real 로 승격된 계정은 mode=real). env CONNECTOR_<DOMAIN>=real 은 "이 도메인에서 real 연동을 허용한다"는 게이트. 시드 mock 계정(mode=mock)은 env 가 real 이어도 mock 으로 남아 데모/CI 결정성을 지킨다 — real 데이터는 OAuth 로 새로 연결한 계정(mode=real)에서만 들어온다. 따라서 mock 과 real 이 공존할 수 있다(예: 메일은 real, 금융은 mock).


3.5 도메인 구현 — mail (대표 사례, 가장 상세)

3.5.1 mail/mock.py — 시드 기반 (기본값)

mail-data.js: emails 를 그대로 fetch 한다. 이미 phase-9 가 email 테이블에 시드했다면 mock 은 "no-op sync"(이미 있는 데이터)로 동작하되, 데모용 "새 메일 1건 도착" 시뮬레이션을 위한 simulate_new() 진입점을 둔다.

# backend/app/connectors/mail/mock.py
from datetime import datetime, timezone
from sqlmodel import Session
from ..base import BaseConnector, RawRecord, NormalizedRecord
from .normalize import normalize_email

# mail-data.js emails 에서 가져온 "새로 도착할" 시뮬레이션 메일(연합 데모용)
_SIM = [{
    "id": "sim-m1", "account": "work", "from": "hyunwoo", "to": "나",
    "subject": "온보딩 시안 v3 — 금요일까지 피드백 부탁드려요",
    "date": "오늘 09:12", "read": False, "starred": False,
    "preview": "지우님, 리뷰에서 나온 코멘트 반영해서 v3 정리했어요…",
    "labels": ["디자인"],
}]


class MockMailConnector(BaseConnector):
    domain = "mail"
    entity_type = "email"

    def fetch(self, session: Session, *, full: bool = False):
        # mock: 기본은 빈 스트림(시드는 이미 적재됨). 데모 시 simulate 로 1건 흘림.
        src = getattr(self, "_sim_payloads", [])
        for p in src:
            yield RawRecord(external_id=p["id"], payload=p,
                            etag=p.get("date", ""),
                            external_updated_at=datetime.now(timezone.utc))

    def simulate_new(self):
        """데모: '새 메일 도착' 1건을 다음 sync 에 흘린다(연합 트리거)."""
        self._sim_payloads = _SIM
        return self

    def normalize(self, raw: RawRecord) -> NormalizedRecord:
        return normalize_email(self.account, raw)   # provider-무관 정규화 공유

    def write(self, session: Session, norm: NormalizedRecord):
        return _upsert_email(session, norm)

    def event_for(self, norm, entity_id):
        return "mail.received"

3.5.2 mail/real_gmail.py — Gmail API

OAuth2 액세스 토큰으로 Gmail REST(users.messages.list + users.messages.get)를 호출하고, 증분은 historyId(델타) 또는 q=after:<epoch> 로 처리한다.

# backend/app/connectors/mail/real_gmail.py
import base64
import httpx
from datetime import datetime, timezone
from sqlmodel import Session
from ..base import BaseConnector, RawRecord, NormalizedRecord
from ..oauth import valid_access_token        # 만료 시 자동 refresh
from ..ratelimit import RateLimiter
from .normalize import normalize_email
from ...config import get_settings

API = "https://gmail.googleapis.com/gmail/v1"


class GmailConnector(BaseConnector):
    domain = "mail"
    entity_type = "email"
    _rl = RateLimiter(rate=4, per=1.0)   # Gmail per-user QPS 보수적 제한

    def fetch(self, session: Session, *, full: bool = False):
        st = self._state_row(session)
        token = valid_access_token(session, self.account)   # token_expired 면 예외 → sync 가 error 처리
        headers = {"Authorization": f"Bearer {token}"}
        timeout = get_settings().connector_http_timeout
        page_size = get_settings().sync_page_size
        params = {"maxResults": page_size, "q": "" if full else "newer_than:7d"}
        if st.cursor and not full:
            params["q"] = f"after:{st.cursor}"              # epoch seconds 커서
        with httpx.Client(timeout=timeout) as c:
            self._rl.acquire()
            r = c.get(f"{API}/users/me/messages", headers=headers, params=params)
            self._rl.handle_response(r)                     # 429 → 백오프
            r.raise_for_status()
            for m in r.json().get("messages", []):
                self._rl.acquire()
                d = c.get(f"{API}/users/me/messages/{m['id']}", headers=headers,
                          params={"format": "full"})
                self._rl.handle_response(d); d.raise_for_status()
                msg = d.json()
                yield RawRecord(external_id=msg["id"], payload=msg,
                                etag=msg.get("historyId", ""),
                                external_updated_at=_epoch_to_dt(msg.get("internalDate")))
        st.cursor = str(int(datetime.now(timezone.utc).timestamp()))
        session.add(st); session.commit()

    def normalize(self, raw: RawRecord) -> NormalizedRecord:
        return normalize_email(self.account, raw, provider="gmail")

    def write(self, session: Session, norm: NormalizedRecord):
        return _upsert_email(session, norm)

    def event_for(self, norm, entity_id):
        return "mail.received"


def _epoch_to_dt(ms: str | None):
    return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc) if ms else None

mail/real_imap.py(HEY/일반 IMAP)는 imaplibUID SEARCH SINCE 증분을 구현하되 동일 RawRecord/normalize_email 로 귀결한다(로컬 우선 대안 — OAuth 불필요).

3.5.3 mail/normalize.py — provider → 내부 email 모델 (정규화 골든)

provider 가 무엇이든 동일한 내부 email 필드로 매핑한다. phase-9 의 email 모델 필드(account/from/to/subject/body/labels/read/starred/attachments + ai{...})를 타깃으로 한다. 내부 ai 묶음(summary/priority/category/tasks/events/replies/file)은 로컬 LLM(phase-9 의 메일 분석) 이 채운다 — provider 는 raw 만 준다.

# backend/app/connectors/mail/normalize.py
from datetime import datetime, timezone
from ..base import RawRecord, NormalizedRecord
from ...models import ConnectorAccount

# mail-data.js account id ↔ connector_account 매핑(시드 정합)
_ACCOUNT_KEY = {"ca-mail-work": "work", "ca-mail-personal": "personal", "ca-mail-side": "side"}


def normalize_email(account: ConnectorAccount, raw: RawRecord, provider: str = "mock") -> NormalizedRecord:
    p = raw.payload
    if provider == "gmail":
        headers = {h["name"].lower(): h["value"]
                   for h in p.get("payload", {}).get("headers", [])}
        fields = {
            "account": _ACCOUNT_KEY.get(account.id, "work"),
            "from_addr": headers.get("from", ""),
            "to_addr": headers.get("to", "나"),
            "subject": headers.get("subject", "(제목 없음)"),
            "body": _gmail_body(p),
            "labels": _gmail_labels(p.get("labelIds", [])),
            "read": "UNREAD" not in p.get("labelIds", []),
            "starred": "STARRED" in p.get("labelIds", []),
            "has_attach": _gmail_has_attach(p),
            "received_at": _internal_dt(p.get("internalDate")),
            "ai": None,          # phase-9 메일 분석(로컬 LLM)이 후처리로 채움
        }
    else:  # mock(mail-data.js shape) / imap 등은 이미 내부 키와 유사
        fields = {
            "account": p.get("account", "work"),
            "from_addr": p.get("from", ""), "to_addr": p.get("to", "나"),
            "subject": p.get("subject", ""), "body": "\n".join(p.get("body", [])) if isinstance(p.get("body"), list) else p.get("body", ""),
            "labels": p.get("labels", []), "read": p.get("read", False),
            "starred": p.get("starred", False), "has_attach": p.get("hasAttach", False),
            "received_at": datetime.now(timezone.utc), "ai": p.get("ai"),
        }
    return NormalizedRecord(entity_type="email", external_id=raw.external_id,
                            fields=fields, etag=raw.etag,
                            external_updated_at=raw.external_updated_at)

def _internal_dt(ms):
    return datetime.fromtimestamp(int(ms) / 1000, tz=timezone.utc) if ms else datetime.now(timezone.utc)
# _gmail_body / _gmail_labels / _gmail_has_attach: MIME 파트 파싱(생략 — base64url 디코딩, 라벨 한국어 매핑)

정규화 골든의 의미: test_normalize_golden.py 가 "Gmail 페이로드 픽스처 → 위 fields dict" 의 매핑을 고정한다. provider 가 바뀌어도(IMAP/HEY) 동일 fields 로 귀결되어야 페이지(메일/알림/작업)가 코드 변경 없이 동작한다. 라벨은 Gmail CATEGORY_PROMOTIONS 등 → 내부 한국어 라벨(["디자인"], ["금융"]mail-data.js 톤)로 매핑한다.

3.5.4 공통 upsert — email 멱등 저장

# backend/app/connectors/mail/_upsert.py (또는 normalize.py 하단)
import uuid
from sqlmodel import Session, select
from ...models import Email, ExternalLink     # Email = phase-9 모델

def _upsert_email(session: Session, norm) -> tuple[str, bool]:
    link = session.exec(select(ExternalLink).where(
        ExternalLink.external_id == norm.external_id,
        ExternalLink.entity_type == "email")).first()
    if link:                                   # 이미 존재 → update(멱등)
        e = session.get(Email, link.entity_id)
        if e:
            for k, v in norm.fields.items():
                if v is not None and hasattr(e, k):
                    setattr(e, k, v)
            session.add(e)
            return e.id, False
    eid = "m-" + uuid.uuid4().hex[:8]
    e = Email(id=eid, **{k: v for k, v in norm.fields.items() if v is not None})
    session.add(e)
    return eid, True

다른 도메인(calendar/finance/health/knowledge/chat)도 정확히 같은 4단 구조(mock.py / real_*.py / normalize.py / _upsert)를 따른다. 표는 §3.6.


3.6 도메인별 매핑 요약표

도메인 mock 출처(시드) real 제공자 로컬 우선 대안 정규화 타깃(내부 모델, phase) 발행 이벤트
mail mail-data.js: emails/accounts Gmail API, Google Workspace IMAP(HEY/일반) email(phase-9) mail.received
calendar cal-data.js: events/focusBlocks Google Calendar API .ics 파일 임포트 event/focus_block/meeting(phase-8) calendar.updated
chat (phase-9 알림 시드) 메신저 Webhook/API notification/chat_message(phase-9) chat.message
finance life-data.js: finance 카드/뱅크 아그리게이터 CSV 임포트(카드 명세) finance/finance_tx(phase-11) finance.transaction
health life-data.js: health Google Fit API HealthKit export(.zip/.xml) health(rings/vitals/sleep)(phase-11) health.sample
knowledge life-data.js: knowledge Notion API, Readwise 웹 클리퍼, Markdown 노트 knowledge_item + RAG ingest(phase-10/11) knowledge.ingested

도메인 구현 디테일 메모:

  • calendar: Google Calendar 증분은 syncToken(델타) — events.list?syncToken=..., 410 GONE 시 full resync 후 새 토큰 획득. .ics 임포트(ics_import.py)는 icalendar 파싱 → 동일 event 정규화(로컬 우선, OAuth 불필요). 정규화는 cal-data.js{day,start,end,title,cal,loc,people,note,soon} 키로 귀결.
  • finance: 한국 금융 OAuth 아그리게이터는 불안정/지역 제약 → CSV 임포트를 1급 시민으로(csv_import.py). 우리카드/토스 CSV 헤더 → finance_tx{date, merchant, amount, category, account}. 카테고리는 life-data.js: finance.cats(식비/쇼핑/교통/카페·간식/구독/문화·여가) 로 매핑. 금액 단위는 원본대로 정수 원화.
  • health: Apple Health 는 공식 API 가 없어 HealthKit export(.xml) 임포트가 정본(healthkit_import.py) — <Record type="HKQuantityTypeIdentifierStepCount" .../> 파싱 → health.rings(걸음/운동/수면). Google Fit 은 OAuth real. 원본 life-data.js 의 단위·문구("7,240보", "7h 12m", "안정 심박 62 bpm")를 표시 포맷으로 보존.
  • knowledge: Notion/Readwise/클리퍼 → knowledge_item{type: article|note|idea|highlight, title, src, excerpt, tags, ai} (life-data.js shape) + RAG ingest 트리거(phase-10 backend/app/rag/ingest). knowledge_item 은 리서치(phase-10)와 라이프(phase-11)가 공유.
  • chat: 인바운드 Webhook(real_webhook.py)으로 메신저 메시지 수신 → notification 트리아지(phase-9)로 흘림. OAuth 대신 서명 검증.

3.7 OAuth2 공통 헬퍼 — connectors/oauth.py

Authorization Code + PKCE. start(인증 URL 생성·state 저장) → callback(code→token 교환·암호화 저장) → refresh(만료 시 자동).

# backend/app/connectors/oauth.py
import base64, hashlib, os, secrets, time
import httpx
from sqlmodel import Session
from ..config import get_settings
from ..crypto import encrypt_token, decrypt_token
from ..models import ConnectorAccount, OAuthState, ConnState

PROVIDERS = {
    "google": {  # Gmail + Google Calendar
        "auth": "https://accounts.google.com/o/oauth2/v2/auth",
        "token": "https://oauth2.googleapis.com/token",
        "scopes": {"gmail": ["https://www.googleapis.com/auth/gmail.readonly"],
                   "google_calendar": ["https://www.googleapis.com/auth/calendar.events"]},
    },
    "notion": {"auth": "https://api.notion.com/v1/oauth/authorize",
               "token": "https://api.notion.com/v1/oauth/token", "scopes": {"notion": []}},
}


def start_oauth(session: Session, domain: str, provider: str, redirect_after: str = "/life") -> str:
    st = get_settings()
    fam = "google" if provider in ("gmail", "google_calendar") else provider
    conf = PROVIDERS[fam]
    state = secrets.token_urlsafe(24)
    verifier = base64.urlsafe_b64encode(os.urandom(40)).decode().rstrip("=")
    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=")
    session.add(OAuthState(id=state, domain=domain, provider=provider,
                           code_verifier=verifier, redirect_after=redirect_after))
    session.commit()
    scopes = " ".join(conf["scopes"].get(provider, []))
    params = {"client_id": st.google_client_id if fam == "google" else st.notion_client_id,
              "redirect_uri": st.google_redirect_uri, "response_type": "code",
              "scope": scopes, "state": state, "access_type": "offline", "prompt": "consent",
              "code_challenge": challenge, "code_challenge_method": "S256"}
    return f"{conf['auth']}?" + "&".join(f"{k}={httpx.QueryParams({k:v})[k]}" for k, v in params.items())


def finish_oauth(session: Session, code: str, state: str) -> ConnectorAccount:
    os_row = session.get(OAuthState, state)
    if not os_row:
        raise ValueError("invalid oauth state")
    st = get_settings()
    fam = "google" if os_row.provider in ("gmail", "google_calendar") else os_row.provider
    conf = PROVIDERS[fam]
    data = {"code": code, "grant_type": "authorization_code",
            "redirect_uri": st.google_redirect_uri, "code_verifier": os_row.code_verifier,
            "client_id": st.google_client_id if fam == "google" else st.notion_client_id,
            "client_secret": st.google_client_secret if fam == "google" else st.notion_client_secret}
    r = httpx.post(conf["token"], data=data, timeout=st.connector_http_timeout)
    r.raise_for_status()
    tok = r.json()
    tok["expires_at"] = int(time.time()) + int(tok.get("expires_in", 3600))
    # 새 real 계정 생성(또는 기존 계정 승격) → mode=real
    acct = _ensure_account(session, os_row.domain, os_row.provider)
    acct.mode = "real"; acct.state = ConnState.connected
    acct.token_enc = encrypt_token(tok); acct.scopes = tok.get("scope", "")
    acct.last_label = "방금 연결됨"
    session.add(acct); session.delete(os_row); session.commit()
    return acct


def valid_access_token(session: Session, account: ConnectorAccount) -> str:
    """만료되면 refresh_token 으로 갱신. 갱신 불가면 token_expired 로 표시 후 예외."""
    tok = decrypt_token(account.token_enc)
    if not tok:
        account.state = ConnState.token_expired; session.add(account); session.commit()
        raise PermissionError("token missing/corrupt")
    if tok.get("expires_at", 0) > int(time.time()) + 60:
        return tok["access_token"]
    # refresh
    st = get_settings()
    fam = "google" if account.provider in ("gmail", "google_calendar") else account.provider
    conf = PROVIDERS[fam]
    rt = tok.get("refresh_token")
    if not rt:
        account.state = ConnState.token_expired; session.add(account); session.commit()
        raise PermissionError("no refresh_token")
    r = httpx.post(conf["token"], data={
        "grant_type": "refresh_token", "refresh_token": rt,
        "client_id": st.google_client_id if fam == "google" else st.notion_client_id,
        "client_secret": st.google_client_secret if fam == "google" else st.notion_client_secret},
        timeout=st.connector_http_timeout)
    if r.status_code != 200:
        account.state = ConnState.token_expired; session.add(account); session.commit()
        raise PermissionError("refresh failed")
    new = r.json(); new["refresh_token"] = rt
    new["expires_at"] = int(time.time()) + int(new.get("expires_in", 3600))
    account.token_enc = encrypt_token(new); session.add(account); session.commit()
    return new["access_token"]

3.8 레이트리밋 — connectors/ratelimit.py

# backend/app/connectors/ratelimit.py
import threading, time
import httpx


class RateLimiter:
    """간단 토큰 버킷 + 429/Retry-After 지수 백오프."""
    def __init__(self, rate: float = 4, per: float = 1.0, max_backoff: float = 32.0):
        self.rate, self.per = rate, per
        self.allowance = rate; self.last = time.monotonic()
        self.max_backoff = max_backoff; self._lock = threading.Lock()

    def acquire(self):
        with self._lock:
            now = time.monotonic()
            self.allowance += (now - self.last) * (self.rate / self.per); self.last = now
            if self.allowance > self.rate: self.allowance = self.rate
            if self.allowance < 1.0:
                time.sleep((1.0 - self.allowance) * (self.per / self.rate)); self.allowance = 0.0
            else:
                self.allowance -= 1.0

    def handle_response(self, r: httpx.Response, attempt: int = 0):
        if r.status_code == 429:
            retry = float(r.headers.get("Retry-After", min(2 ** attempt, self.max_backoff)))
            time.sleep(min(retry, self.max_backoff))
            return True   # 호출측이 재시도
        return False

3.9 라우터 — routers/connectors.py

phase-2 규약: 라우터 내부 prefix 없음, main.py 에서 include_router(connectors.router, prefix="/api", tags=["connectors"]).

# backend/app/routers/connectors.py
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import RedirectResponse
from sqlmodel import Session, select
from ..db import get_session
from ..models import ConnectorAccount, ConnectorAccountLog, ConnState
from ..connectors.registry import ConnectorRegistry
from ..connectors import oauth as oauthlib
from ..schemas import (ConnectorStatusOut, OAuthStartOut, SyncResultOut, ImportResultOut)
from ..config import get_settings

router = APIRouter()   # prefix 없음. main.py 에서 prefix="/api".


@router.get("/connectors", response_model=list[ConnectorStatusOut])
def list_connectors(domain: str | None = None, s: Session = Depends(get_session)):
    rows = ConnectorRegistry.accounts(s, domain)
    return [ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain)) for a in rows]


@router.post("/connectors/{account_id}/sync", response_model=SyncResultOut)
def sync_one(account_id: str, full: bool = False, s: Session = Depends(get_session)):
    a = s.get(ConnectorAccount, account_id)
    if not a: raise HTTPException(404, "connector account not found")
    conn = ConnectorRegistry.get(s, a)
    res = conn.sync(s, full=full)
    return SyncResultOut(**res.__dict__)


@router.post("/connectors/sync-all", response_model=list[SyncResultOut])
def sync_all(s: Session = Depends(get_session)):
    out = []
    for a in ConnectorRegistry.accounts(s):
        if a.state == ConnState.disconnected:    # 연결 안 된 계정은 건너뜀
            continue
        out.append(SyncResultOut(**ConnectorRegistry.get(s, a).sync(s).__dict__))
    return out


# ── OAuth ──
@router.get("/connectors/oauth/start", response_model=OAuthStartOut)
def oauth_start(domain: str, provider: str, redirect_after: str = "/life",
                s: Session = Depends(get_session)):
    if not get_settings().google_client_id and provider in ("gmail", "google_calendar"):
        raise HTTPException(400, "google_client_id 미설정 — real 연동 비활성")
    url = oauthlib.start_oauth(s, domain, provider, redirect_after)
    return OAuthStartOut(authorize_url=url)

@router.get("/connectors/oauth/callback")
def oauth_callback(code: str, state: str, s: Session = Depends(get_session)):
    try:
        acct = oauthlib.finish_oauth(s, code, state)
    except Exception as e:
        return RedirectResponse(url=f"{get_settings().frontend_origin}/life?connect=error")
    # 연결 직후 1회 초기 sync
    ConnectorRegistry.get(s, acct).sync(s, full=True)
    redirect = s and "/life"
    return RedirectResponse(url=f"{get_settings().frontend_origin}/life?connect=ok&domain={acct.domain}")


@router.post("/connectors/{account_id}/disconnect", response_model=ConnectorStatusOut)
def disconnect(account_id: str, s: Session = Depends(get_session)):
    a = s.get(ConnectorAccount, account_id)
    if not a: raise HTTPException(404, "not found")
    a.state = ConnState.disconnected; a.token_enc = ""; a.last_label = "연결 안 됨"
    import uuid
    s.add(a); s.add(ConnectorAccountLog(id="cl-"+uuid.uuid4().hex[:8], account_id=a.id,
                                        action="disconnected", detail="사용자 해제"))
    s.commit()
    return ConnectorStatusOut.from_account(a, ConnectorRegistry.mode_for(a.domain))


# ── 로컬 우선: 수동 임포트(CSV / .ics / HealthKit export) ──
@router.post("/connectors/{account_id}/import", response_model=ImportResultOut)
async def import_file(account_id: str, file: UploadFile = File(...),
                      s: Session = Depends(get_session)):
    a = s.get(ConnectorAccount, account_id)
    if not a: raise HTTPException(404, "not found")
    content = await file.read()
    conn = ConnectorRegistry.get(s, a)              # csv/ics/healthkit 모드 커넥터
    if not hasattr(conn, "import_bytes"):
        raise HTTPException(400, "이 커넥터는 파일 임포트를 지원하지 않아요")
    res = conn.import_bytes(s, content, filename=file.filename)   # 내부에서 fetch→normalize→write
    return ImportResultOut(**res)

연결 흐름(real): 프론트가 GET /api/connectors/oauth/start?domain=mail&provider=gmail 으로 authorize_url 을 받아 새 창으로 이동 → 사용자 동의 → 제공자가 GET /api/connectors/oauth/callback?code=&state= 호출 → 토큰 교환·암호화 저장·초기 full sync → frontend_origin/life?connect=ok 로 리다이렉트. 로컬 우선 흐름(import): POST /api/connectors/{id}/import 멀티파트 업로드(CSV/.ics/HealthKit export) → OAuth 없이 즉시 정규화·저장.


3.10 능동 레이어 — worker/sync_jobs.py (주기 sync)

phase-7 의 backend/app/worker/(APScheduler)에 주기 동기화 잡을 등록한다. 프로토타입은 수동 트리거(/api/connectors/sync-all)도 제공(데모 결정성).

# backend/app/worker/sync_jobs.py
from ..config import get_settings
from ..db import engine
from sqlmodel import Session
from ..connectors.registry import ConnectorRegistry
from ..models import ConnState


def run_periodic_sync():
    """worker 스케줄러가 sync_interval_minutes 마다 호출. real/연결된 계정만 sync."""
    with Session(engine) as s:
        for a in ConnectorRegistry.accounts(s):
            if a.state in (ConnState.connected, ConnState.error) and a.mode.value != "mock":
                ConnectorRegistry.get(s, a).sync(s)


def register(scheduler):
    """phase-7 worker.main 에서 호출."""
    mins = get_settings().sync_interval_minutes
    scheduler.add_job(run_periodic_sync, "interval", minutes=mins,
                      id="connector_sync", replace_existing=True)

mock 계정은 주기 sync 대상이 아니다(데이터가 변하지 않음). real/연결 계정만 sync_interval_minutes(기본 15분) 간격으로 증분 sync → 새 객체에서만 이벤트 발행 → 자동화/알림 작동.


4. 데이터/타입/API 계약

4.1 신규 엔드포인트 (prefix /api)

메서드 경로 용도
GET /api/connectors?domain= 커넥터 계정 상태 목록(ConnectorStatusOut[]) — 라이프 카드/설정에서 사용
POST /api/connectors/{account_id}/sync?full= 단일 계정 수동 동기화 → SyncResultOut
POST /api/connectors/sync-all 연결된 전 계정 동기화 → SyncResultOut[]
GET /api/connectors/oauth/start?domain=&provider=&redirect_after= OAuth 인증 URL 발급 → OAuthStartOut
GET /api/connectors/oauth/callback?code=&state= OAuth 콜백(토큰 교환·저장·초기 sync) → 프론트 리다이렉트
POST /api/connectors/{account_id}/disconnect 연결 해제(토큰 폐기) → ConnectorStatusOut
POST /api/connectors/{account_id}/import (multipart) CSV/.ics/HealthKit export 수동 임포트 → ImportResultOut

4.2 Pydantic 스키마 (schemas.py 추가, types.ts 와 1:1)

# backend/app/schemas.py  (추가)
from datetime import datetime
from typing import Optional
from pydantic import BaseModel

class ConnectorStatusOut(BaseModel):
    id: str
    domain: str
    provider: str
    name: str                     # "회사" / "우리카드" / "Apple Health"
    kind: str                     # "Lumi Workspace" / "신용/체크" / "건강 앱"
    tone: str                     # blue|violet|coral|green|amber|ink|faint
    mode: str                     # mock | csv | healthkit | real
    env_mode: str                 # CONNECTOR_<DOMAIN> 값(real 허용 여부 표시)
    state: str                    # connected|disconnected|syncing|error|token_expired
    on: bool                      # state==connected (라이프 카드 on 호환)
    last: str                     # last_label ("방금 동기화"/"오늘 09:12"/"연결 안 됨")
    last_synced_at: Optional[datetime] = None
    error_detail: str = ""

    @classmethod
    def from_account(cls, a, env_mode: str) -> "ConnectorStatusOut":
        return cls(id=a.id, domain=a.domain, provider=a.provider, name=a.name,
                   kind=a.kind, tone=a.tone, mode=a.mode, env_mode=env_mode,
                   state=a.state, on=(a.state == "connected"), last=a.last_label,
                   last_synced_at=a.last_synced_at, error_detail=a.error_detail)

class OAuthStartOut(BaseModel):
    authorize_url: str

class SyncResultOut(BaseModel):
    domain: str
    account_id: str
    seen: int = 0
    upserted: int = 0
    skipped: int = 0
    errors: int = 0
    events_published: list[str] = []
    detail: str = ""

class ImportResultOut(BaseModel):
    entity_type: str
    imported: int = 0
    skipped: int = 0
    errors: int = 0
    detail: str = ""
// frontend/lib/types.ts  (추가 — 백엔드 키 snake_case 유지)
export type ConnState =
  | "connected" | "disconnected" | "syncing" | "error" | "token_expired";

export interface ConnectorStatus {
  id: string;
  domain: "mail" | "calendar" | "chat" | "finance" | "health" | "knowledge";
  provider: string;
  name: string;
  kind: string;
  tone: "blue" | "violet" | "coral" | "green" | "amber" | "ink" | "faint";
  mode: "mock" | "csv" | "healthkit" | "real";
  env_mode: string;
  state: ConnState;
  on: boolean;
  last: string;
  last_synced_at: string | null;
  error_detail: string;
}
export interface OAuthStart { authorize_url: string; }
export interface SyncResult {
  domain: string; account_id: string;
  seen: number; upserted: number; skipped: number; errors: number;
  events_published: string[]; detail: string;
}
export interface ImportResult {
  entity_type: string; imported: number; skipped: number; errors: number; detail: string;
}

4.3 요청/응답 JSON 예시

GET /api/connectors?domain=health (시드 mock — life-data.js: sources.health 그대로):

[
  {"id":"ca-health-apple","domain":"health","provider":"apple_health","name":"Apple Health",
   "kind":"건강 앱","tone":"coral","mode":"mock","env_mode":"mock","state":"connected",
   "on":true,"last":"방금 동기화","last_synced_at":null,"error_detail":""},
  {"id":"ca-health-watch","domain":"health","provider":"apple_watch","name":"Apple Watch",
   "kind":"웨어러블","tone":"violet","mode":"mock","env_mode":"mock","state":"connected",
   "on":true,"last":"1분 전","last_synced_at":null,"error_detail":""},
  {"id":"ca-health-fit","domain":"health","provider":"google_fit","name":"Google Fit",
   "kind":"활동 추적","tone":"green","mode":"mock","env_mode":"mock","state":"disconnected",
   "on":false,"last":"연결 안 됨","last_synced_at":null,"error_detail":""}
]

POST /api/connectors/ca-mail-personal/sync (Gmail real, 새 메일 2건):

{
  "domain": "mail", "account_id": "ca-mail-personal",
  "seen": 12, "upserted": 2, "skipped": 10, "errors": 0,
  "events_published": ["mail.received", "mail.received"],
  "detail": ""
}

GET /api/connectors/oauth/start?domain=calendar&provider=google_calendar:

{ "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=...&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.events&state=...&code_challenge=...&code_challenge_method=S256" }

POST /api/connectors/ca-fin-woori/import (우리카드 CSV 업로드, multipart):

{ "entity_type": "finance_tx", "imported": 47, "skipped": 3, "errors": 0,
  "detail": "우리카드 6월 명세 · 식비 412,000 · 쇼핑 298,000 정규화 완료" }

5. 디자인 충실도 노트

기준 파일(REF): design-reference/assets/life-data.js, mail-data.js, cal-data.js, life.css, dash.css(토큰).

5.1 라이프 connector 카드 — sources 의 on/last 그대로

원본 life-data.js: sources 의 각 항목은 {id, name, kind, tone, on, last} 다. 이 carded UI 를 Phase 13 이 실제 커넥터 상태로 채운다(값은 mock 일 때 원본과 1바이트 동일).

원본 life-data.js:
  { id:"apple",  name:"Apple Health", kind:"건강 앱",  tone:"coral",  on:true,  last:"방금 동기화" }
  { id:"toss",   name:"토스",          kind:"계좌 통합", tone:"coral",  on:true,  last:"오늘 08:40" }
  { id:"fit",    name:"Google Fit",   kind:"활동 추적", tone:"green",  on:false, last:"연결 안 됨" }
→ ConnectorStatusOut: name/kind/tone 동일, on=state=="connected", last=last_label

ConnectorCard.tsx 는 phase-11 의 라이프 카드를 재사용한다. 톤은 ("coral" 등)로 받아 var(--coral)(=#df7256, dash.css) 로 변환(CONTRACT: tone 은 백엔드 키 저장, 프론트 var(--tone) 변환). on:false 카드는 흐리게(--faint #b6b3ad) + "연결 안 됨" + [연결] 버튼.

5.2 메일 계정 톤·라벨 보존

mail-data.js: accounts 의 3계정 톤(work=coral, personal=blue, side=violetkind(Lumi Workspace/Google/HEY)·unread 배지를 그대로 유지. 정규화 라벨은 원본 메일 labels(["디자인"], ["경영진"], ["금융"], ["채용"], ["영수증"] …)와 동일 한국어 라벨로 매핑.

5.3 상태 배지 색 (dash.css 액센트 그대로)

상태 라벨 tone/색
connected "방금 동기화" / "오늘 09:12" / "1분 전" --green #4e9b66
syncing "동기화 중…" --blue #4f72e0 (스피너)
disconnected "연결 안 됨" --faint #b6b3ad
error "동기화 실패 · 다시 시도" --coral #df7256
token_expired "권한 만료 · 다시 연결" --amber #e0a23c

글래스 카드는 background: var(--glass) + backdrop-filter: var(--blur)(blur(26px) saturate(190%)) + border: 1px solid var(--glass-brd)(dash.css §11.4). 라운드 --radius 22px. 라이트/다크는 data-theme(next-themes) 그대로.

5.4 캘린더/건강/금융 표시 포맷 보존

  • 일정: cal-data.jsstart/end("09:30"), loc("Zoom"/"대회의실"), soon:true(분기 전략 미팅) 보존. real 동기화 시에도 동일 키.
  • 건강: "7,240보 / 10,000보 / 72%", "수면 7h 12m", "안정 심박 62 bpm" 등 .mono(tabular-nums) 표시.
  • 금융: 금액 정수 원화(1,284,000), 카테고리 6종, 구독 spark — life-data.js: finance 포맷 그대로.

6. 상태 처리 & 엣지 케이스

상황 기대 동작 구현 포인트
로딩 커넥터 목록/sync 중 스켈레톤 + "동기화 중…" 배지 state=syncing, 프론트 낙관적 표시
빈 상태 도메인에 연결 계정 0개 "아직 연결된 OO이 없어요 · [연결하기]" 빈-상태 카드
연결(connect) OAuth 동의 후 connect=ok 리다이렉트 + 초기 full sync oauth_callback 에서 1회 full sync
해제(disconnect) 토큰 폐기(token_enc=""), state→disconnected, last→"연결 안 됨" /disconnect, 데이터는 보존(이미 받은 메일 유지)
오류(error) sync 예외 → state=error, error_detail 저장, 서버 안 죽음 BaseConnector.syncexcept rollback + _finish_error
동기화 중(syncing) 재진입 sync 방지 권장(같은 계정 동시 sync) state==syncing 이면 409 또는 skip
권한 만료(token_expired) refresh 실패 → state=token_expired, "다시 연결" CTA valid_access_token 의 PermissionError → sync error → UI 재인증
레이트리밋(429) Retry-After 만큼 백오프 후 재시도, 초과 시 부분 결과 commit RateLimiter.handle_response
중복/충돌 같은 external_id 재수신 → upsert(중복 0). etag 동일 → skip external_link UNIQUE + _is_duplicate
오프라인(Ollama/네트워크 끊김) mock 은 정상, real 은 fetch 실패→error 로 표시(데모는 mock 폴백) env 기본 mock, real 실패가 앱을 멈추지 않음
CSV 형식 오류 파싱 실패 행 skip + errors 카운트, 정상 행만 import csv_import.import_bytes 행 단위 try/except
암호화 키 손상/회전 decrypt_token{} → token_expired (재연결 유도) crypto.decrypt_token InvalidToken 폴백
mock↔real 공존 메일=real, 금융=mock 동시 가능 계정별 mode, env 는 도메인 게이트
시드 mock 보호 env=real 이어도 시드 mock 계정은 mock 유지(데모 결정성) ConnectorRegistry.get 모드 결정 정본

엣지 메모:

  • GOOGLE_CLIENT_ID 미설정인데 /oauth/start 호출 → 400 "real 연동 비활성"(앱 안 죽음).
  • .ics/CSV 임포트는 OAuth 불필요 → 클라이언트 비밀 없이도 로컬 우선 데이터 유입 보장.
  • 재동기화로 같은 메일이 들어와도 created=Falsemail.received 재발행 안 함(중복 알림 방지).

7. 연합 이벤트 (발행/구독)

CONTRACT 의 event_bus(phase-7 정본) 이벤트 모델을 사용한다. 이 phase 의 핵심 가치: mock 에서는 시뮬레이션이던 이벤트가 real 에서 실제 외부 데이터로 발행되어, phase 7~12 의 구독자가 진짜 데이터로 동작한다.

7.1 이 phase가 발행하는 이벤트

이벤트 발행 시점 페이로드 주 구독자
mail.received mail sync 가 새 email upsert(created=True) {account_id, entity_type:"email", entity_id} phase-9(메일 분석 → email.ai.tasks/events), 자동화 evaluator(영수증/뉴스레터 정리 규칙), 알림 트리아지
calendar.updated calendar sync 가 새/변경 event upsert {account_id, entity_type:"event", entity_id} phase-8(일정 표시·회의 도우미), 집중 모드 빈 블록 재계산, 리스크 레이더
finance.transaction finance sync/CSV import 가 새 거래 {account_id, entity_type:"finance_tx", entity_id} phase-11(예산/카테고리 갱신), 자동화(예산 초과·잠자는 구독 제안 → 결재함)
health.sample health sync/HealthKit import 가 새 샘플 {account_id, entity_type:"health_sample", entity_id} phase-11(rings/sleep 갱신, 코치 리마인더)
knowledge.ingested knowledge sync 가 새 knowledge_item {account_id, entity_type:"knowledge_item", entity_id} phase-10 RAG ingest(chunk→embed), 리서치 Q&A·라이프 지식
chat.message chat webhook 수신 {account_id, entity_type:"chat_message", entity_id} phase-9 알림 트리아지
connector.synced 모든 도메인 sync 완료 {account_id, upserted, skipped, errors} phase-12 하루 마감(동기화 요약), 라이프 카드 last 갱신
connector.error sync 실패/토큰 만료 {account_id, detail, state} 알림(재연결 안내), 라이프 카드 error 배지

7.2 이 phase가 구독하는 이벤트

구독 이벤트 출처 처리
automation.matched (mail/calendar 동작) phase-7 evaluator BaseConnector.write()(예: calendar_write tool → real 캘린더에 이벤트 쓰기) — 양방향(write 메서드)
worker.tick (스케줄러) phase-7 worker run_periodic_sync() 실행(증분 sync)

7.3 연합 시나리오 (PROJECT-README §5 — 실데이터 버전)

[real Gmail 연결됨] 현우님 "온보딩 시안 v3" 메일 도착
   → GmailConnector.sync(): fetch → normalize_email → write(email) created=True
   → event_bus.publish("mail.received", {entity_id:"m-xxxx"})
   → phase-9 메일 분석(로컬 LLM): ai.tasks=[{온보딩 시안 v3 피드백 정리, due 6/13, 높음}]
       ai.events=[], ai.replies=[{수락·일정 약속, ...}]   (mail-data.js m1 의 ai 묶음과 동일 형태)
   → 자동화 evaluator: "검토 요청 메일 → 작업 초안" 규칙 매칭
   → autonomy_setting=mixed, risk=low → 작업 자동 생성(되돌리기) → 결재함 a 항목
   → 작업 페이지 GET /api/tasks 에 등장 (federation, 개인=프로젝트 원칙)
   → 하루 마감: "아리가 처리" 카운트 +1, 아낀 시간 합산

이 흐름은 mock(MockMailConnector.simulate_new())에서도 동일 코드로 재현된다 — 그래서 데모/테스트가 결정적이다. real 은 같은 이벤트를 진짜 메일에서 만들 뿐이다(인터페이스 불변의 증거).


8. 테스팅 & 검증

8.1 실행 명령

# 의존성 추가(uv)
cd /Users/kim1634/mycloud/personal/workspace/ari_assistant/workspace/backend
uv add cryptography
uv add --dev respx
# (pip 폴백: pip install cryptography && pip install respx)

# 마이그레이션 + 시드(커넥터 mock 계정 포함)
uv run alembic upgrade head
uv run python -m app.seed         # run_seed() → _seed_connectors() 포함

# 백엔드 테스트
uv run pytest tests/test_connector_contract.py tests/test_connector_swap.py \
              tests/test_oauth_mock.py tests/test_sync_idempotent.py \
              tests/test_normalize_golden.py tests/test_crypto.py \
              tests/test_federation_real.py -q
uv run pytest -q                  # 전체(기존 phase 회귀 포함)

# 수동 스모크
curl -s http://localhost:8000/api/connectors?domain=health | python3 -m json.tool
curl -s -X POST http://localhost:8000/api/connectors/ca-mail-work/sync | python3 -m json.tool

# 프론트
cd ../frontend && pnpm test
pnpm exec playwright test playwright/connectors.spec.ts

8.2 테스트 케이스 목록

# 케이스 방법 통과 기준
C1 인터페이스 계약: 모든 도메인 mock/real 이 fetch/normalize/write/sync 구현 test_connector_contract.py — 각 클래스가 BaseConnector 서브클래스 + 메서드 존재 6개 도메인 × (mock + real/alt) 전부 통과, 추상 미구현 0
C2 Mock↔Real 스왑 동일 shape: CONNECTOR_MAIL=mock vs real(fake) 에서 /api/connectors 응답 키 동일 test_connector_swap.py — env monkeypatch + 응답 스키마 비교 ConnectorStatusOut 키/타입 동일, 페이지 코드 변경 0
C3 OAuth 모킹: start→callback→token 저장(암호화) test_oauth_mock.pyrespx 로 token 엔드포인트 모킹 connector_account.token_enc 비어있지 않고 평문 access_token 미포함, state row 소거
C4 동기화 멱등성: 같은 페이로드 2회 sync → 중복 0 test_sync_idempotent.py — fake fetch 동일 레코드 2회 1회차 upserted=N, 2회차 upserted=0 & skipped=N, external_link row N개
C5 정규화 골든: Gmail 픽스처 → 고정 email.fields test_normalize_golden.py — 픽스처 vs 기대 dict 필드별 정확 매핑(subject/from/labels/read/starred)
C6 토큰 암복호화 왕복 test_crypto.py decrypt(encrypt(x))==x, 잘못된 키 → {} (예외 누수 없음)
C7 연합(real fake) → 이벤트 발행 test_federation_real.py — fake GmailConnector sync → event_bus 스파이 새 메일당 mail.received 1회, 재sync 시 0회
C8 토큰 만료 → token_expired refresh 401 모킹 state=token_expired, sync errors≥1, 서버 200(앱 안 죽음)
C9 레이트리밋 백오프: 429 → Retry-After 후 재시도 RateLimiter 단위 + 429 모킹 백오프 호출 발생, 최종 성공
C10 CSV 임포트(로컬 우선): 우리카드 CSV → finance_tx import_bytes 단위 + 오류 행 skip imported>0, 오류 행 skip+errors 카운트
C11 .ics 임포트: 이벤트 → event 정규화 IcsConnector.import_bytes cal-data.js 키로 귀결
C12 disconnect 토큰 폐기 /disconnect 호출 token_enc=="", state=disconnected, 기존 데이터 보존
C13 mock 시드 보호: env=real 인데 시드 mock 계정 registry 모드 결정 시드 계정 mock 유지(데모 결정성)
C14 회귀: phase 2~12 기존 테스트 uv run pytest -q 전부 green(인터페이스 불변 증명)

8.3 대표 테스트 코드

# backend/tests/test_sync_idempotent.py
from app.connectors.mail.mock import MockMailConnector
from app.models import ConnectorAccount, ExternalLink, Email
from sqlmodel import select

def test_sync_is_idempotent(session, seed):
    acct = session.get(ConnectorAccount, "ca-mail-work")
    conn = MockMailConnector(acct).simulate_new()       # 새 메일 1건 흘림
    r1 = conn.sync(session)
    conn2 = MockMailConnector(acct).simulate_new()      # 같은 external_id 재공급
    r2 = conn2.sync(session)
    assert r1.upserted == 1 and r2.upserted == 0 and r2.skipped == 1
    links = session.exec(select(ExternalLink).where(
        ExternalLink.account_id == "ca-mail-work")).all()
    assert len([l for l in links if l.external_id == "sim-m1"]) == 1   # 중복 0
# backend/tests/test_oauth_mock.py
import respx, httpx
from app.connectors import oauth
from app.crypto import decrypt_token

@respx.mock
def test_oauth_callback_stores_encrypted_token(session, monkeypatch):
    monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid"); monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "sec")
    url = oauth.start_oauth(session, "mail", "gmail")
    state = url.split("state=")[1].split("&")[0]
    respx.post("https://oauth2.googleapis.com/token").mock(
        return_value=httpx.Response(200, json={
            "access_token": "AT", "refresh_token": "RT", "expires_in": 3600, "scope": "...readonly"}))
    acct = oauth.finish_oauth(session, code="CODE", state=state)
    assert acct.mode == "real" and acct.state == "connected"
    assert "AT" not in acct.token_enc            # 평문 노출 금지
    assert decrypt_token(acct.token_enc)["access_token"] == "AT"
# backend/tests/test_connector_swap.py
def test_status_shape_same_mock_and_real(client, monkeypatch):
    monkeypatch.setenv("CONNECTOR_MAIL", "mock")
    mock_keys = set(client.get("/api/connectors?domain=mail").json()[0].keys())
    monkeypatch.setenv("CONNECTOR_MAIL", "real")
    real_keys = set(client.get("/api/connectors?domain=mail").json()[0].keys())
    assert mock_keys == real_keys     # 페이지가 보는 shape 불변

8.4 수동 QA 체크리스트

  • python -m app.seed/api/connectors 가 mail 3 + calendar 1 + health 3 + finance 4 + knowledge 3 = 14 계정 반환, 값이 mail-data.js/life-data.js 와 동일.
  • Google Fit·KB증권은 on:false + last:"연결 안 됨".
  • 라이프 페이지 connector 카드가 /api/connectors 값으로 렌더(톤·last 문구 원본 일치).
  • CONNECTOR_* 전부 mock(기본)에서 모든 페이지가 phase 7~12 와 동일하게 동작(회귀 없음).
  • GOOGLE_CLIENT_ID 미설정 시 /oauth/start?provider=gmail → 400(앱 안 죽음), mock 페이지 정상.
  • (자격증명 있을 때) OAuth start → 동의 → callback → connect=ok 리다이렉트 + 초기 sync → 메일 등장.
  • 같은 계정 sync 2회 → 두 번째 upserted:0(중복 없음).
  • CSV(우리카드 명세) 임포트 → 금융 거래/카테고리 갱신, OAuth 불필요.
  • .ics 임포트 → 일정에 이벤트 등장, OAuth 불필요.
  • sync 실패(네트워크 끊김) → 카드 error 배지 + "다시 시도", 서버 200·앱 살아있음.
  • 토큰 만료 시 "권한 만료 · 다시 연결"(amber) 배지.
  • DB 의 connector_account.token_enc평문 토큰이 보이지 않음(grep 으로 access_token 미검출).
  • disconnect 후 토큰 폐기되지만 이미 받은 메일/일정은 유지.

8.5 통과 기준 (요약)

C1~C14 전부 green + 수동 체크리스트 전 항목. 핵심 합격선: (1) CONNECTOR_*=mock 기본에서 phase 7~12 회귀 0(인터페이스 불변), (2) real(fake) sync 가 동일 코드 경로로 mail.received/calendar.updated 를 발행, (3) 동기화 멱등(중복 0), (4) 토큰 평문 미저장, (5) 모든 외부 실패가 앱을 멈추지 않음(mock/로컬 폴백).


9. 완료 기준 (Definition of Done)

  • backend/app/connectors/: base.py(fetch/sync/normalize/write 추상 + 공통 upsert/멱등/이벤트), registry.py(CONNECTOR_ 선택), oauth.py(Auth Code+PKCE start/callback/refresh), ratelimit.py, crypto.py 존재.
  • 6개 도메인(mail/calendar/chat/finance/health/knowledge) 각각 mock.py + real_*.py(또는 csv/ics/healthkit alt) + normalize.py 존재, 모두 BaseConnector 계약 충족.
  • 신규 5개 테이블(connector_account, connector_sync_state, external_link(복합 UNIQUE), oauth_state, connector_account_log) Alembic 마이그레이션 + 왕복 OK.
  • _seed_connectors()run_seed() 에 통합되어 14개 mock 계정 시드(값 = mail-data.js/life-data.js/cal-data.js 원본).
  • config.pyCONNECTOR_<DOMAIN>(기본 mock) + OAuth/암호화 env, .env 예시 갱신.
  • 라우터 /api/connectors(상태/sync/sync-all/oauth start·callback/disconnect/import) + main.py include_router(prefix="/api").
  • mock 기본에서 phase 7~12 전부 회귀 0(코드 변경 없이 동작) — 인터페이스 불변 증명.
  • real(fake) sync → event_busmail.received/calendar.updated/… 발행 → 자동화/작업/알림이 실데이터로 동작.
  • 증분 sync(cursor) + 정규화(provider→내부) + 멱등 upsert(external_link UNIQUE) + 레이트리밋(429 백오프) 동작.
  • 로컬 우선: CSV/.ics/HealthKit export 수동 임포트가 OAuth 없이 동작.
  • 토큰 암호화 저장(평문 미저장) + 만료 자동 refresh + 실패 시 token_expired.
  • 라이프 connector 카드 on/last/상태(connected/disconnected/syncing/error/token_expired) 디자인 충실 렌더(tone=키→var(--tone)).
  • C1~C14 테스트 green + 수동 QA 체크리스트 전 항목.

10. 다음 단계

  • 다음 문서: phase-14-proactive-agent.md — 능동형 에이전트(심부름 대행) + 멀티모달 캡처(음성 STT/이미지 Vision 실구현) + 능동 알림. Phase 13 의 RealConnector 가 공급하는 실데이터 이벤트 스트림(mail.received/calendar.updated/finance.transaction/health.sample) 위에서, backend/app/agents/(plan→act→observe→reflect) 와 backend/app/worker/(다이제스트/브리핑/패턴 탐지)가 동작한다. 에이전트 tool(calendar_write/task_create/form_fill)은 본 phase 의 BaseConnector.write() 를 호출해 외부에 쓰기까지 연합한다.
  • 이후 phase-15-production.md — 인증/멀티유저(per-user connector_account.user_id 스코프), 토큰 시크릿 매니저·키 회전, 배포/관측성(sync 지표·이벤트 추적)/성능/보안.

빌드 순서(CONTRACT): 7 → 8 → 9 → 10 → 11 → 12 → 13 → 14 → 15. 페이지는 mock-first 로 만들고, 13(이 문서)에서 real 로 전환한다.