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