Files
auto-stock/backend/app/services/tenant/registry.py
shuim b4a1fc79ab feat: 멀티테넌트 1단계 — 사용자별 KIS 계좌·포트폴리오·수동매매 (MULTI_TENANT_ENABLED 기본 OFF)
설계: docs/multi_tenant_design.md / 검수: docs/multi_tenant_phase1_review.md (High 0, MT=False 배포 가능)

데이터·인프라
- user_kis_credentials(Fernet 암호화, PBKDF2 키 파생, key_version), stock_bans_v2 신설,
  portfolio/trade_history/active_trades/trade_journals/trading_sessions 등 user_id nullable 추가
- alembic 0009~0011(idempotent, legacy owner 백필). main.py 기동 시 portfolio.user_id 스키마 검사(H1)
- Jenkinsfile: DB Migration 을 Deploy 앞으로, alembic 이력 없으면 stamp 0008 후 upgrade(N1)
- bootstrap: users 비어 있으면 BOOTSTRAP_ADMIN_* 로 관리자 시드 + .env KIS 자격증명 이관 + 백필(SAVEPOINT)

백엔드
- core/crypto.py, core/tenant_auth.py(get_current_user — 비활성 사용자 401, API 키→legacy owner,
  deny_non_owner_session_when_mt), core/dt_utils.py
- services/tenant: context, kis_pool, registry(UserContextRegistry, 락 최소화·복호화 to_thread), risk_factory
- KoreaInvestmentAPI.for_user(사용자별 인스턴스, 토큰캐시 users/{id}/, 시세는 공용 위임)
- trade_executor.safe_place_order_v2(사용자별 락/세마포어, legacy ctx 는 기존 키)
- mdd_manager/loss_tracker _v2 + ban_until tz-aware/naive 경계 정규화(레거시 경로 버그 포함 수정)
- db_worker: payload user_id 옵션 필터, upsert_stock_ban_v2, legacy ActiveTrade 조회 LIMIT 1
- API: /account/credentials(등록·마스킹조회·삭제·연결테스트·자동매매), /auth/admin/tenant/*,
  /portfolio/v2(조회·요약·sync·buy·sell), /trades/v2(+pending). PortfolioResponse Optional 수정

프론트
- /account 내 계좌 설정, useTenantMode(세션=v2 / API키=legacy, 미확정 시 쿼리·버튼 비활성),
  계좌 미등록 인라인 안내, v2 매수/매도/거래이력 분기

테스트: tests/integration(격리 Postgres, KIS 전면 mock) 20 passed / 1 xfailed(0012 이전 제약)
2단계(스케줄러 사용자화·legacy 라우터 격리) 전까지 MULTI_TENANT_ENABLED=True 전환 금지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:26:51 +09:00

252 lines
11 KiB
Python

"""사용자 실행 컨텍스트 레지스트리 (설계 문서 §2, §5.6).
`UserContext` 조립(자격증명 복호화 → KIS 클라이언트 풀 → 리스크 매니저 팩토리 →
상태 복원)을 한곳에 모아 캐싱한다. FastAPI 의존성 `get_user_context()`도 여기서
제공한다(트레이딩 실행/엔드포인트 양쪽에서 재사용).
- DB 세션은 항상 호출 측이 넘겨준다 — 이 모듈은 `AsyncSessionLocal()`을 직접
열지 않는다(§CLAUDE.md 아키텍처 원칙 1). `iter_active()`만 예외적으로 스케줄러가
세션을 만들어 넘겨주는 것을 전제로 `db` 인자를 필수로 받는다.
- 복호화 실패 등 개별 사용자 오류는 그 사용자만 None/스킵 처리하고 다른 사용자에
전파하지 않는다.
"""
import logging
import time
import asyncio
from fastapi import Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.crypto import CredentialDecryptError, decrypt
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user, get_legacy_owner
from app.models.models import UserKisCredential
from app.services.tenant.context import UserContext
from app.services.tenant.kis_pool import kis_client_pool
from app.services.tenant.risk_factory import (
create_loss_tracker_for_user,
create_mdd_manager_for_user,
load_user_risk_state,
)
logger = logging.getLogger(__name__)
_LEGACY_OWNER_TTL_SEC = 60
_ACTIVE_LIST_TTL_SEC = 60
class UserContextRegistry:
"""사용자별 `UserContext` 캐시 + 조립 로직."""
def __init__(self) -> None:
self._contexts: dict[int, UserContext] = {}
self._lock = asyncio.Lock()
self._legacy_ctx: UserContext | None = None
self._legacy_owner_id: int | None = None
self._legacy_checked_at: float = 0.0
self._legacy_lock = asyncio.Lock()
self._legacy_owner_confirmed: bool = False
self._active_cache: list[UserContext] | None = None
self._active_cached_at: float = 0.0
# ── 사용자별 컨텍스트 조립 ────────────────────────────────────────────
async def get(self, user_id: int, db: AsyncSession) -> UserContext | None:
"""캐시 히트면 즉시 반환, 미스면 DB에서 자격증명을 읽어 새로 조립한다.
M4: `asyncio.Lock` 은 캐시 dict 갱신 구간(더블체크 패턴)만 감싼다 — DB I/O,
PBKDF2 복호화(스레드로 위임), KIS 클라이언트 생성, Redis/DB 리스크 상태 복원은
모두 락 밖에서 수행해 이벤트 루프 직렬화를 피한다. 동시에 같은 user_id 로 호출되면
중복 조립이 일어날 수 있으나(락 밖 조립) 마지막에 dict 갱신만 락으로 보호하고
먼저 캐시된 인스턴스를 우선한다.
"""
cached = self._contexts.get(user_id)
if cached is not None:
return cached
result = await db.execute(
select(UserKisCredential).where(UserKisCredential.user_id == user_id)
)
cred = result.scalar_one_or_none()
if cred is None or not cred.is_enabled:
logger.debug(f"[UserContextRegistry] user={user_id} 자격증명 없음/비활성 — None 반환")
return None
try:
app_key = await asyncio.to_thread(decrypt, cred.app_key_enc, key_version=cred.key_version)
app_secret = await asyncio.to_thread(decrypt, cred.app_secret_enc, key_version=cred.key_version)
account_no = await asyncio.to_thread(decrypt, cred.account_no_enc, key_version=cred.key_version)
except CredentialDecryptError as e:
logger.error(f"[UserContextRegistry] user={user_id} 자격증명 복호화 실패: {e}")
raise HTTPException(
status_code=400, detail="자격증명 복호화 실패 — 서버 키 변경 여부 확인",
) from e
try:
kis = await kis_client_pool.get_or_create(
user_id=user_id,
app_key=app_key,
app_secret=app_secret,
account_no=account_no,
mode=cred.mode,
)
except Exception as e:
logger.error(f"[UserContextRegistry] user={user_id} KIS 클라이언트 생성 실패: {e}")
return None
mdd = create_mdd_manager_for_user(user_id)
loss_tracker = create_loss_tracker_for_user(user_id)
await load_user_risk_state(user_id, mdd, loss_tracker, db)
ctx = UserContext(
user_id=user_id,
username="",
mode=cred.mode,
kis=kis,
mdd=mdd,
loss_tracker=loss_tracker,
auto_trade_enabled=bool(cred.auto_trade_enabled),
is_legacy=False,
)
async with self._lock:
existing = self._contexts.get(user_id)
if existing is not None:
return existing
self._contexts[user_id] = ctx
return ctx
# ── legacy(단일 계좌) 컨텍스트 ────────────────────────────────────────
async def legacy(self, db: AsyncSession | None = None) -> UserContext:
"""기존 전역 싱글톤(`korea_investment_api`/`mdd_manager`/`loss_tracker`)을 감싼 컨텍스트.
owner id는 60초 TTL로 재확인한다(관리자 계정이 나중에 생기는 초기 부팅 구간 대응).
owner 가 아직 없으면(관리자 미존재) `user_id=None`으로 반환하고 TTL 캐시를 걸지
않는다(M1) — `users.id=0`은 존재하지 않는 FK 위반 값이라 0 대신 None 으로 통일한다.
"""
now = time.monotonic()
if (
self._legacy_ctx is not None
and self._legacy_owner_confirmed
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
):
return self._legacy_ctx
async with self._legacy_lock:
now = time.monotonic()
if (
self._legacy_ctx is not None
and self._legacy_owner_confirmed
and (now - self._legacy_checked_at) < _LEGACY_OWNER_TTL_SEC
):
return self._legacy_ctx
owner_id: int | None = None
if db is not None:
try:
owner = await get_legacy_owner(db)
if owner is not None:
owner_id = owner.id
except Exception as e:
logger.warning(f"[UserContextRegistry] legacy owner 조회 실패: {e}")
from app.services.korea_investment_api import korea_investment_api
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
self._legacy_ctx = UserContext(
user_id=owner_id,
username="legacy",
mode=settings.KIS_MODE,
kis=korea_investment_api,
mdd=mdd_manager,
loss_tracker=loss_tracker,
auto_trade_enabled=settings.AUTO_TRADE_ENABLED,
is_legacy=True,
)
self._legacy_owner_id = owner_id
self._legacy_owner_confirmed = owner_id is not None
self._legacy_checked_at = now
return self._legacy_ctx
# ── 인증 사용자 → 컨텍스트 해석 ───────────────────────────────────────
async def resolve(self, current_user: CurrentUser, db: AsyncSession) -> UserContext:
"""`CurrentUser` → `UserContext`. 실패 시 400 HTTPException."""
legacy_ctx = await self.legacy(db)
use_legacy = (
not settings.MULTI_TENANT_ENABLED
or current_user.is_api_key
or (current_user.id is not None and current_user.id == legacy_ctx.user_id)
)
if use_legacy:
return legacy_ctx
if current_user.id is None:
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
ctx = await self.get(current_user.id, db)
if ctx is None:
raise HTTPException(status_code=400, detail="KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 등록하세요")
return ctx
# ── 2단계 스케줄러용: 활성 사용자 전체 순회 ───────────────────────────
async def iter_active(self, db: AsyncSession) -> list[UserContext]:
"""`is_enabled and auto_trade_enabled` 인 모든 사용자의 컨텍스트 (60초 TTL 캐시).
지금은 조립 로직만 제공 — 실제 2단계 스케줄러 연결은 이번 범위 밖.
"""
now = time.monotonic()
if self._active_cache is not None and (now - self._active_cached_at) < _ACTIVE_LIST_TTL_SEC:
return self._active_cache
result = await db.execute(
select(UserKisCredential).where(
UserKisCredential.is_enabled.is_(True),
UserKisCredential.auto_trade_enabled.is_(True),
)
)
creds = result.scalars().all()
contexts: list[UserContext] = []
for cred in creds:
try:
ctx = await self.get(cred.user_id, db)
except Exception as e:
logger.error(f"[UserContextRegistry] iter_active: user={cred.user_id} 조립 실패: {e}")
ctx = None
if ctx is not None:
contexts.append(ctx)
self._active_cache = contexts
self._active_cached_at = now
return contexts
# ── 캐시 무효화 ───────────────────────────────────────────────────────
async def invalidate(self, user_id: int) -> None:
"""자격증명 변경/삭제 시 credentials API가 호출 — 컨텍스트 캐시 + KIS 풀 정리."""
async with self._lock:
self._contexts.pop(user_id, None)
await kis_client_pool.invalidate(user_id)
self._active_cache = None
async def invalidate_all(self) -> None:
async with self._lock:
self._contexts.clear()
self._active_cache = None
# 모듈 싱글톤
user_context_registry = UserContextRegistry()
async def get_user_context(
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> UserContext:
"""FastAPI 의존성 — 세션/API 키 인증 사용자를 `UserContext`로 해석."""
return await user_context_registry.resolve(user, db)