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:
@@ -9,6 +9,8 @@ SQLAlchemy ORM 모델 정의
|
||||
- ExchangeRate: 환율 이력
|
||||
- UsStockUniverse: 미국 주식 종목 유니버스
|
||||
- User: 대시보드 로그인 계정 (bcrypt 해시, 관리자 승인)
|
||||
- UserKisCredential: 사용자별 KIS 자격증명 (암호화 저장, 멀티테넌트 1단계)
|
||||
- StockBanV2: 사용자별 3연패 밴 (StockBan 은 legacy owner 전용 단일 테이블로 유지)
|
||||
"""
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
@@ -40,8 +42,10 @@ class StockDailyPrice(Base):
|
||||
class TradingSession(Base):
|
||||
"""🔥 일일 트레이딩 세션 관리 (리스크 관리)"""
|
||||
__tablename__ = "trading_sessions"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# 멀티테넌트 1단계: nullable — 기존 session_date unique 는 유지 (0012에서 (user_id, session_date)로 전환)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
session_date = Column(Date, default=date.today, unique=True, index=True)
|
||||
|
||||
consecutive_losses = Column(Integer, default=0)
|
||||
@@ -122,6 +126,8 @@ class TradeHistory(Base):
|
||||
__tablename__ = "trade_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# 멀티테넌트 1단계: nullable — MULTI_TENANT_ENABLED=False 인 동안은 채우지 않음(legacy 컨텍스트 조회 시 필터 미적용)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
rule_id = Column(Integer)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100))
|
||||
@@ -155,6 +161,8 @@ class Portfolio(Base):
|
||||
__tablename__ = "portfolio"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# 멀티테넌트 1단계: nullable, 기존 unique(stock_code)는 이번 단계에서 유지 (0012에서 (user_id, stock_code)로 전환)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, unique=True)
|
||||
stock_name = Column(String(100))
|
||||
quantity = Column(Integer, nullable=False, default=0)
|
||||
@@ -201,8 +209,9 @@ class News(Base):
|
||||
|
||||
class ClaudeAnalysisLog(Base):
|
||||
__tablename__ = "claude_analysis_log"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
|
||||
rule_id = Column(Integer)
|
||||
stock_code = Column(String(20))
|
||||
analysis_type = Column(String(50))
|
||||
@@ -215,8 +224,9 @@ class ClaudeAnalysisLog(Base):
|
||||
|
||||
class MCPToolCall(Base):
|
||||
__tablename__ = "mcp_tool_calls"
|
||||
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) # 감사 추적용, 필수 아님
|
||||
tool_name = Column(String(100), nullable=False)
|
||||
parameters = Column(JSONB)
|
||||
result = Column(JSONB)
|
||||
@@ -264,6 +274,8 @@ class ActiveTrade(Base):
|
||||
__tablename__ = "active_trades"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
# 멀티테넌트 1단계: nullable — db_worker upsert 키에 user_id 추가는 2단계
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True) # [FIX] 인덱스 추가
|
||||
stock_name = Column(String(100))
|
||||
quantity = Column(Integer, nullable=False)
|
||||
@@ -317,6 +329,7 @@ class TradeJournal(Base):
|
||||
__tablename__ = "trade_journals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
trade_id = Column(Integer)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
stock_name = Column(String(100))
|
||||
@@ -331,6 +344,8 @@ class TradeJournal(Base):
|
||||
|
||||
|
||||
class StockBan(Base):
|
||||
"""레거시(단일 계좌) 3연패 밴 테이블. PK=stock_code 라 사용자별 분리 불가 →
|
||||
멀티테넌트에서는 [[StockBanV2]] 사용, 이 테이블은 legacy owner 전용으로 유지."""
|
||||
__tablename__ = "stock_bans"
|
||||
|
||||
stock_code = Column(String(20), primary_key=True)
|
||||
@@ -339,6 +354,26 @@ class StockBan(Base):
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class StockBanV2(Base):
|
||||
"""사용자별 3연패 밴 (멀티테넌트). stock_bans 와 달리 PK 대신 (user_id, stock_code) unique.
|
||||
|
||||
§3.2 근거: stock_bans.PK=stock_code 라 사용자 분리 시 PK 변경이 필요한데, 이는
|
||||
기존 시그니처 변경에 해당하므로 새 테이블로 분리. 0011 백필로 stock_bans → 여기로
|
||||
legacy owner 행을 복제한다(양쪽 유지, legacy 경로는 계속 stock_bans 사용).
|
||||
"""
|
||||
__tablename__ = "stock_bans_v2"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "stock_code", name="uq_stock_bans_v2_user_stock"),
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
stock_code = Column(String(20), nullable=False, index=True)
|
||||
consecutive_losses = Column(Integer, default=0, nullable=False)
|
||||
ban_until = Column(DateTime, nullable=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class WatchlistStock(Base):
|
||||
__tablename__ = "watchlist_stocks"
|
||||
|
||||
@@ -403,3 +438,29 @@ class User(Base):
|
||||
is_admin = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login_at = Column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class UserKisCredential(Base):
|
||||
"""사용자별 KIS(한국투자증권) 자격증명 (멀티테넌트 1단계).
|
||||
|
||||
- app_key/app_secret/account_no 는 [[app.core.crypto]] 로 암호화해 *_enc 컬럼에 저장.
|
||||
- app_key_hash: 동일 앱키 중복 등록 방지용 sha256 hex (평문 앱키의 해시, 조회만 목적 — 복호화 불가).
|
||||
- 1단계는 사용자당 1세트(user_id unique). 모의/실전 동시 등록은 2단계에서 UniqueConstraint(user_id, mode)로 완화.
|
||||
"""
|
||||
__tablename__ = "user_kis_credentials"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
mode = Column(String(10), nullable=False) # virtual | real
|
||||
app_key_enc = Column(Text, nullable=False)
|
||||
app_secret_enc = Column(Text, nullable=False)
|
||||
account_no_enc = Column(Text, nullable=False) # 하이픈 제거 10자리 (계좌 8자리 + 상품코드 2자리)
|
||||
account_product_code = Column(String(2), nullable=False, default="01")
|
||||
app_key_hash = Column(String(64), unique=True, nullable=False) # sha256(app_key) hex — 중복 등록 방지
|
||||
app_key_hint = Column(String(8), nullable=True) # 마스킹 표시용 앞 6자
|
||||
is_enabled = Column(Boolean, default=False, nullable=False) # 자동매매 대상 여부 (연결 테스트 성공 시 True 허용)
|
||||
auto_trade_enabled = Column(Boolean, default=False, nullable=False)
|
||||
last_verified_at = Column(DateTime, nullable=True)
|
||||
key_version = Column(Integer, default=1, nullable=False) # 암호화 키 로테이션용
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
Reference in New Issue
Block a user