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:
@@ -1,9 +1,51 @@
|
||||
/**
|
||||
* useTradeActions.ts — 수동 매수/매도 액션 훅
|
||||
*
|
||||
* v2/legacy 분기:
|
||||
* - v2(로그인 세션): `/portfolio/v2/buy`, `/portfolio/v2/sell`
|
||||
* - legacy(API 키 세션, MULTI_TENANT_ENABLED=false 포함): 기존 `/portfolio/buy`, `/portfolio/sell`
|
||||
* 훅 시그니처(handleBuy/handleSell 파라미터·반환 타입)는 그대로 유지하고 내부 구현만
|
||||
* `useTenantMode()`로 분기한다 — Dashboard.tsx 등 호출부 수정 없이 자동 전환.
|
||||
*
|
||||
* 계좌 미등록/미검증(400/423) 에러는 `err.isAccountSetupRequired = true`로 표시해
|
||||
* 호출부가 alert 대신 인라인 안내(AccountSetupNotice)를 띄울 수 있게 한다.
|
||||
*
|
||||
* 리뷰 M5/M6:
|
||||
* - `mode`가 아직 확정되지 않은 동안(undefined)은 v2/legacy 어느 쪽으로도 주문을 내지
|
||||
* 않도록 매수/매도/미체결정리 모두 즉시 거부한다(`isReady=false`). 호출부는
|
||||
* `isReady`로 버튼을 disabled 처리해야 한다.
|
||||
* - `resetPendingTrades`는 legacy(`DELETE /trades/pending`)가 사용자 무관 전체 삭제이므로
|
||||
* v2 모드에서는 사용자 스코프 `DELETE /trades/v2/pending`으로 분기한다.
|
||||
*/
|
||||
import { useCallback } from 'react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
import { AxiosError } from 'axios'
|
||||
import api, { buyStockV2, sellStockV2, resetPendingTradesV2 } from '../lib/api'
|
||||
import { useTenantMode } from '../lib/tenant'
|
||||
|
||||
/** 계좌 미등록/미검증(400/423) 여부를 표시하는 확장 에러 타입 */
|
||||
export type TradeActionError = AxiosError<{ detail?: string }> & { isAccountSetupRequired?: boolean }
|
||||
|
||||
const markAccountSetupError = (error: unknown): TradeActionError => {
|
||||
const err = error as TradeActionError
|
||||
const status = err?.response?.status
|
||||
if (status === 400 || status === 423) {
|
||||
err.isAccountSetupRequired = true
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
/** mode 미확정 상태에서 호출됐을 때 던지는 에러 — 계좌 설정 안내가 아니라 "아직 준비 안 됨" */
|
||||
class TenantModeNotReadyError extends Error {
|
||||
constructor() {
|
||||
super('사용자 모드를 확인하는 중입니다. 잠시 후 다시 시도하세요.')
|
||||
}
|
||||
}
|
||||
|
||||
export const useTradeActions = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { mode } = useTenantMode()
|
||||
const isReady = mode !== undefined
|
||||
|
||||
const handleSell = useCallback(
|
||||
async (
|
||||
@@ -11,14 +53,16 @@ export const useTradeActions = () => {
|
||||
stockName: string,
|
||||
quantity: number
|
||||
): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
// KIS 모의서버 지연 시 주문 1건에 30~60초 걸릴 수 있음 — 기본 15초로 끊으면
|
||||
// 서버는 체결하는데 화면은 실패로 보여 중복 주문을 유발함
|
||||
await api.post('/portfolio/sell', {
|
||||
stock_code: stockCode,
|
||||
quantity,
|
||||
price: null,
|
||||
}, { timeout: 90000 })
|
||||
const body = { stock_code: stockCode, quantity, price: null }
|
||||
if (mode === 'v2') {
|
||||
await sellStockV2(body)
|
||||
} else {
|
||||
await api.post('/portfolio/sell', body, { timeout: 90000 })
|
||||
}
|
||||
|
||||
// Invalidate queries to refresh portfolio
|
||||
queryClient.invalidateQueries({
|
||||
@@ -29,44 +73,50 @@ export const useTradeActions = () => {
|
||||
})
|
||||
|
||||
console.log(`✅ ${stockName} sell order completed`)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = markAccountSetupError(error)
|
||||
console.error(
|
||||
`❌ Sell order failed for ${stockName}:`,
|
||||
error.response?.data?.detail || error.message
|
||||
err.response?.data?.detail || err.message
|
||||
)
|
||||
throw error
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
[queryClient, mode]
|
||||
)
|
||||
|
||||
/** 추천 종목 수동 매수 (시장가, 수량은 서버가 예산 기반 산출) */
|
||||
const handleBuy = useCallback(
|
||||
async (stockCode: string, stockName: string): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
await api.post('/portfolio/buy', {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
quantity: null,
|
||||
price: null,
|
||||
}, { timeout: 90000 })
|
||||
const body = { stock_code: stockCode, stock_name: stockName, quantity: null, price: null }
|
||||
if (mode === 'v2') {
|
||||
await buyStockV2(body)
|
||||
} else {
|
||||
await api.post('/portfolio/buy', body, { timeout: 90000 })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||
console.log(`✅ ${stockName} buy order completed`)
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
const err = markAccountSetupError(error)
|
||||
console.error(
|
||||
`❌ Buy order failed for ${stockName}:`,
|
||||
error.response?.data?.detail || error.message
|
||||
err.response?.data?.detail || err.message
|
||||
)
|
||||
throw error
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[queryClient]
|
||||
[queryClient, mode]
|
||||
)
|
||||
|
||||
const resetPendingTrades = useCallback(async (): Promise<void> => {
|
||||
if (mode === undefined) throw new TenantModeNotReadyError()
|
||||
try {
|
||||
const response = await api.delete('/trades/pending')
|
||||
const response = mode === 'v2'
|
||||
? await resetPendingTradesV2()
|
||||
: await api.delete('/trades/pending')
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['trade-history'],
|
||||
})
|
||||
@@ -78,7 +128,7 @@ export const useTradeActions = () => {
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}, [queryClient])
|
||||
}, [queryClient, mode])
|
||||
|
||||
const invalidateTradeHistory = useCallback(() => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -91,5 +141,7 @@ export const useTradeActions = () => {
|
||||
handleBuy,
|
||||
resetPendingTrades,
|
||||
invalidateTradeHistory,
|
||||
/** false면 mode 미확정 — 매수/매도/미체결정리 버튼을 disabled 처리해야 함 */
|
||||
isReady,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user