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>
This commit is contained in:
2026-09-21 10:26:51 +09:00
parent dc128ae4cf
commit b4a1fc79ab
52 changed files with 5614 additions and 96 deletions

View File

@@ -29,7 +29,7 @@ from datetime import datetime
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, select
from sqlalchemy import func, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -89,6 +89,19 @@ class UserOut(BaseModel):
last_login_at: datetime | None
class TenantMigrateOut(BaseModel):
legacy_owner: str | None
credentials: str # created | exists | skipped_no_env | skipped_no_encryption
backfilled: dict[str, int]
class TenantStatusOut(BaseModel):
legacy_owner: str | None
multi_tenant_enabled: bool
credentials_registered: int
null_user_id_counts: dict[str, int]
# ── 헬퍼 ─────────────────────────────────────────────────────────────────
def _hash_password(password: str) -> str:
# passlib 1.7.4 는 bcrypt>=4.1 과 호환되지 않으므로 bcrypt 를 직접 사용 (72바이트 제한)
@@ -274,3 +287,98 @@ async def revoke_user(user_id: int, admin: str = Depends(require_admin), db: Asy
await db.commit()
logger.info(f"[Auth] 계정 비활성화 user={user.username} by={admin}")
return UserOut.model_validate(user, from_attributes=True)
# ── 멀티테넌트 이관 관리자 API (설계문서 §4, §5.10) ─────────────────────────
# `ensure_bootstrap_admin()`(app/services/tenant/bootstrap.py, main.py lifespan 에서
# 앱 기동 시 1회 자동 실행)와 동일한 이관/백필 로직을 관리자가 수동으로 재실행할 수
# 있게 노출한다. 예: 관리자 계정이 앱 기동 이후 처음 생성된 경우(부트스트랩 시점에는
# legacy owner 가 없어 자동 이관이 스킵됨) 이 API로 지연 이관을 수행한다.
@router.post(
"/admin/tenant/migrate-legacy",
response_model=TenantMigrateOut,
dependencies=[Depends(require_admin)],
)
async def migrate_legacy_tenant(db: AsyncSession = Depends(get_db)):
""".env KIS 자격증명을 legacy owner 로 이관 + user_id NULL 행 백필 (idempotent)."""
from app.core.crypto import is_encryption_configured
from app.models.models import UserKisCredential
from app.services.tenant.bootstrap import (
_BACKFILL_TABLES,
_backfill_legacy_owner_ids,
_get_legacy_owner,
_seed_legacy_kis_credential,
)
owner = await _get_legacy_owner(db)
if owner is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="관리자 계정이 없습니다. 먼저 회원가입하세요.")
existing = (
await db.execute(select(UserKisCredential).where(UserKisCredential.user_id == owner.id))
).scalar_one_or_none()
if existing is not None:
cred_result = "exists"
elif not (
settings.KOREA_INVESTMENT_APP_KEY
and settings.KOREA_INVESTMENT_APP_SECRET
and settings.KOREA_INVESTMENT_ACCOUNT_NUMBER
):
cred_result = "skipped_no_env"
elif not is_encryption_configured():
cred_result = "skipped_no_encryption"
else:
await _seed_legacy_kis_credential(db, owner)
cred_result = "created"
# 백필 대상 행 수는 백필 "직전" 상태를 반환(백필 후에는 0이 되므로 결과 확인용으로 직전 값 사용)
# M8: 문장별 SAVEPOINT — 한 테이블(컬럼 미존재 등)의 SELECT 실패가 PostgreSQL
# 트랜잭션을 InFailedSqlTransaction으로 만들어 이후 테이블 카운트까지 연쇄로
# 0 오보되는 것을 방지한다.
backfilled: dict[str, int] = {}
for table in _BACKFILL_TABLES:
try:
async with db.begin_nested():
backfilled[table] = (
await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL"))
).scalar() or 0
except Exception:
backfilled[table] = 0
await _backfill_legacy_owner_ids(db, owner.id)
logger.info(
f"[Auth] 레거시 이관 실행 owner={owner.username} credentials={cred_result} backfilled={backfilled}"
)
return TenantMigrateOut(legacy_owner=owner.username, credentials=cred_result, backfilled=backfilled)
@router.get(
"/admin/tenant/status",
response_model=TenantStatusOut,
dependencies=[Depends(require_admin)],
)
async def tenant_status(db: AsyncSession = Depends(get_db)):
"""legacy owner, MULTI_TENANT_ENABLED, 자격증명 등록 수, 테이블별 user_id NULL 행 수."""
from app.models.models import UserKisCredential
from app.services.tenant.bootstrap import _BACKFILL_TABLES, _get_legacy_owner
owner = await _get_legacy_owner(db)
cred_count = (await db.execute(select(func.count()).select_from(UserKisCredential))).scalar() or 0
null_counts: dict[str, int] = {}
for table in _BACKFILL_TABLES:
try:
null_counts[table] = (
await db.execute(text(f"SELECT COUNT(*) FROM {table} WHERE user_id IS NULL"))
).scalar() or 0
except Exception:
null_counts[table] = 0
return TenantStatusOut(
legacy_owner=owner.username if owner else None,
multi_tenant_enabled=settings.MULTI_TENANT_ENABLED,
credentials_registered=cred_count,
null_user_id_counts=null_counts,
)