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

@@ -176,4 +176,76 @@ export const getUsStockPrice = (ticker: string) =>
export const getUsStockChart = (ticker: string, period: string = 'D') =>
api.get(`/market-data/overseas/chart/${ticker}`, { params: { period } })
// ── 사용자 스코프 v2 API (멀티테넌트 1단계, D그룹) ─────────────────────────
// 응답 스키마는 기존 v1(`/portfolio`, `/trades`)과 동일 필드. 호출자는
// `lib/tenant.ts`의 `useTenantMode()`로 v2/legacy 여부를 판단해 골라 쓴다.
// 423 = 사용자 자동매매 미활성(계좌 미검증), 400 = KIS 계좌 미등록·미검증
// (둘 다 "내 계좌 설정에서 계좌를 등록·검증하세요" 안내 대상).
export interface ManualTradeRequest {
stock_code: string
stock_name?: string
quantity?: number | null
price?: number | null
}
export const getPortfolioV2 = () => api.get('/portfolio/v2')
export const getPortfolioSummaryV2 = () => api.get('/portfolio/v2/summary')
export const syncPortfolioV2 = () => api.post('/portfolio/v2/sync')
export const buyStockV2 = (body: ManualTradeRequest) =>
api.post('/portfolio/v2/buy', body, { timeout: 90000 })
export const sellStockV2 = (body: ManualTradeRequest) =>
api.post('/portfolio/v2/sell', body, { timeout: 90000 })
export const getTradesV2 = (params?: Record<string, unknown>) =>
api.get('/trades/v2', { params })
// M6: legacy `/trades/pending`는 user_id 조건 없이 전체 PENDING/FAILED 이력을 삭제하므로
// v2(로그인 세션) 모드에서는 사용자 스코프 삭제 엔드포인트를 별도로 사용한다.
export const resetPendingTradesV2 = () => api.delete('/trades/v2/pending')
// ── 내 계좌 설정 (KIS 자격증명) ─────────────────────────────────────────────────
export interface KisCredentials {
mode: 'virtual' | 'real'
app_key_hint: string
account_masked: string
is_enabled: boolean
auto_trade_enabled: boolean
last_verified_at: string | null
updated_at: string
}
export interface KisCredentialsInput {
mode: 'virtual' | 'real'
app_key: string
app_secret: string
account_no: string
}
export interface KisVerifyResult {
ok: boolean
balance_summary?: Record<string, unknown>
verified_at: string
}
export const getKisCredentials = () =>
api.get<KisCredentials>('/account/credentials')
export const putKisCredentials = (body: KisCredentialsInput) =>
api.put<KisCredentials>('/account/credentials', body)
export const deleteKisCredentials = () =>
api.delete<void>('/account/credentials')
export const verifyKisCredentials = () =>
api.post<KisVerifyResult>('/account/credentials/verify')
export const patchAutoTrade = (enabled: boolean) =>
api.patch<KisCredentials>('/account/auto-trade', { enabled })
export default api

View File

@@ -0,0 +1,52 @@
/**
* tenant.ts — 멀티테넌트 v2/legacy 전환 스위치
*
* 역할:
* - `GET /auth/me`로 호출 주체(auth_type)를 확인해 포트폴리오·거래이력·매수/매도가
* 사용자 스코프 v2 API(`/portfolio/v2`, `/trades/v2`)를 쓸지, 기존 v1 API를
* 쓸지 결정한다.
* - auth_type === 'session' (로그인 사용자) → v2
* - auth_type === 'api_key' (레거시 운영자 API 키, MULTI_TENANT_ENABLED=false 환경 포함)
* → legacy(v1). 백엔드도 이 경우 `/portfolio/v2/sync`를 스킵 응답하므로 v1 그대로 사용해야 한다.
*
* 캐시: staleTime 5분 — 세션 타입은 로그인 상태에서 바뀌지 않으므로 자주 재조회할 필요 없음.
*
* 리뷰 M5 대응: `/auth/me` 응답이 오기 전에 'legacy'를 반환하면 세션(로그인) 사용자에게
* 잠깐 owner(legacy) 데이터가 노출되고 그 사이 매수/매도가 legacy 계좌로 나갈 수 있다.
* 따라서 결정 전(isLoading) 및 조회 실패(401 등) 시에는 `undefined`를 반환해 호출부가
* 데이터 조회·매매 액션을 모두 보류하도록 한다. 401은 기존 axios 응답 인터셉터가
* `api_key`를 지우고 새로고침(→ 로그인 화면)하므로 별도 처리가 필요 없다.
*/
import { useQuery } from '@tanstack/react-query'
import api from './api'
export interface AuthMe {
username: string
auth_type: 'session' | 'api_key'
is_admin: boolean
}
/** 'v2' | 'legacy' 확정 전(로딩 중·조회 실패)에는 undefined */
export type TenantMode = 'v2' | 'legacy' | undefined
/**
* useTenantMode — 현재 호출 주체의 v2/legacy 모드를 반환
*
* `/auth/me` 조회가 끝나기 전(isLoading)이거나 실패(error)한 동안에는 모드를
* 확정할 수 없으므로 `undefined`를 반환한다. 호출부는 `mode !== undefined`를
* react-query `enabled` 조건과 매수/매도 버튼 disabled 조건으로 사용해야 한다.
*/
export const useTenantMode = () => {
const { data: me, isLoading, error } = useQuery<AuthMe>({
queryKey: ['auth-me'],
queryFn: async () => (await api.get('/auth/me')).data,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
})
const mode: TenantMode = isLoading || error || !me
? undefined
: me.auth_type === 'session' ? 'v2' : 'legacy'
return { mode, me, isLoading, error }
}