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:
59
frontend/src/components/AccountSetupNotice.tsx
Normal file
59
frontend/src/components/AccountSetupNotice.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AccountSetupNotice.tsx — 계좌 미등록/미검증 빈 상태 안내 컴포넌트
|
||||
*
|
||||
* 사용처:
|
||||
* - PortfolioSummary / PortfolioPanel: v2 API가 400(계좌 미등록·미검증)을 반환할 때
|
||||
* - Dashboard: 매수/매도 요청이 400/423(자동매매 비활성)로 거부될 때
|
||||
*
|
||||
* 정책: 에러 토스트 대신 인라인 안내 + `/account` 이동 버튼 (일회성 alert 금지).
|
||||
*/
|
||||
import { Link } from 'react-router-dom'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
|
||||
interface AccountSetupNoticeProps {
|
||||
/** 안내 문구 — 백엔드 detail 메시지를 그대로 넘기거나 기본 문구 사용 */
|
||||
message?: string
|
||||
/** 컴팩트 배너(한 줄) 모드 — 목록/버튼 인근처럼 좁은 영역용 */
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGE = 'KIS 계좌가 등록·검증되지 않았습니다. 내 계좌 설정에서 계좌를 등록·검증하세요.'
|
||||
|
||||
const AccountSetupNotice = ({ message, compact = false, className = '' }: AccountSetupNoticeProps) => {
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-sm border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-[10px] ${className}`}
|
||||
role="alert"
|
||||
>
|
||||
<ShieldAlert size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1 leading-tight">{message || DEFAULT_MESSAGE}</span>
|
||||
<Link
|
||||
to="/account"
|
||||
className="flex-shrink-0 px-2 py-0.5 rounded-sm border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors whitespace-nowrap"
|
||||
>
|
||||
계좌 설정
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 text-xs mb-3 ${className}`}
|
||||
role="alert"
|
||||
>
|
||||
<ShieldAlert size={16} className="flex-shrink-0" />
|
||||
<span className="flex-1">{message || DEFAULT_MESSAGE}</span>
|
||||
<Link
|
||||
to="/account"
|
||||
className="flex-shrink-0 px-3 py-1.5 rounded-md border border-yellow-500/40 hover:bg-yellow-500/20 transition-colors font-medium whitespace-nowrap"
|
||||
>
|
||||
내 계좌 설정으로 이동
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AccountSetupNotice
|
||||
@@ -25,6 +25,8 @@ interface JournalPanelProps {
|
||||
tradeHistory?: TradeHistory[] | undefined
|
||||
/** 미체결 주문 정리 콜백 */
|
||||
onResetPending: () => void
|
||||
/** true면 미체결 정리 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
resetDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,7 +35,7 @@ interface JournalPanelProps {
|
||||
* 상태(State):
|
||||
* - tab: 현재 활성 탭 ('trades' 또는 'journal')
|
||||
*/
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: JournalPanelProps) => {
|
||||
const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending, resetDisabled }: JournalPanelProps) => {
|
||||
const [tab, setTab] = useState<'trades' | 'journal'>('trades')
|
||||
|
||||
return (
|
||||
@@ -69,7 +71,8 @@ const JournalPanel = React.memo(({ journals, tradeHistory, onResetPending }: Jou
|
||||
</div>
|
||||
<button
|
||||
onClick={onResetPending}
|
||||
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors"
|
||||
disabled={resetDisabled}
|
||||
className="px-2 py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/30 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
|
||||
title="미체결 주문 정리"
|
||||
>
|
||||
미체결 정리
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* [Main] — 페이지 컨텐츠 영역
|
||||
*/
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp } from 'lucide-react'
|
||||
import { LayoutDashboard, History, Cpu, Activity, SlidersHorizontal, BarChart3, TrendingUp, KeyRound } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import api from '../lib/api'
|
||||
|
||||
@@ -66,6 +66,7 @@ const Layout = ({ children }: LayoutProps) => {
|
||||
{ path: '/backtest', icon: BarChart3, label: '백테스트' },
|
||||
{ path: '/trades', icon: History, label: '매매 로그' },
|
||||
{ path: '/mcp', icon: Cpu, label: 'MCP 상태' },
|
||||
{ path: '/account', icon: KeyRound, label: '내 계좌' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,7 @@ import React from 'react'
|
||||
import { Activity, TrendingUp, Clock } from 'lucide-react'
|
||||
import { Position } from '../types/trading'
|
||||
import { isUsStock } from '../utils/stockUtils'
|
||||
import AccountSetupNotice from './AccountSetupNotice'
|
||||
|
||||
/**
|
||||
* 가격 포맷 — 시장에 따라 원화 또는 달러 표시
|
||||
@@ -44,6 +45,8 @@ interface PortfolioItemProps {
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산(시장가 매도) 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
sellDisabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +67,7 @@ const PortfolioItem = React.memo(({
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
sellDisabled,
|
||||
}: PortfolioItemProps) => {
|
||||
// 현재가: 실시간 가격 우선, 없으면 포트폴리오 API 가격 사용
|
||||
const currentPrice = livePrices?.[stock.stock_code] ?? Number(stock.current_price || 0)
|
||||
@@ -79,10 +83,13 @@ const PortfolioItem = React.memo(({
|
||||
|
||||
const handleSellClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (sellDisabled) return
|
||||
try {
|
||||
await onSell(stock.stock_code, stock.stock_name, quantity)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||
if (err?.isAccountSetupRequired) return
|
||||
alert(`매도 실패: ${err.response?.data?.detail || err.message}`)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +140,8 @@ const PortfolioItem = React.memo(({
|
||||
<div className="px-3 pb-2">
|
||||
<button
|
||||
onClick={handleSellClick}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors"
|
||||
disabled={sellDisabled}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#3d7eff] border border-[#152035] hover:border-[#3d7eff]/40 rounded-sm transition-colors disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:text-[#3a5570] disabled:hover:border-[#152035]"
|
||||
>
|
||||
전량 청산 (시장가)
|
||||
</button>
|
||||
@@ -158,6 +166,12 @@ interface PortfolioPanelProps {
|
||||
onRefetchAiAnalysis: () => void
|
||||
/** 전량 청산 콜백 */
|
||||
onSell: (stockCode: string, stockName: string, quantity: number) => Promise<void>
|
||||
/** true면 청산 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
sellDisabled?: boolean
|
||||
/** true면 KIS 계좌 미등록/미검증 — 빈 목록 대신 계좌 설정 안내 표시 */
|
||||
accountSetupRequired?: boolean
|
||||
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
|
||||
accountSetupMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +188,9 @@ const PortfolioPanel = React.memo(({
|
||||
onSelectStock,
|
||||
onRefetchAiAnalysis,
|
||||
onSell,
|
||||
sellDisabled,
|
||||
accountSetupRequired,
|
||||
accountSetupMessage,
|
||||
}: PortfolioPanelProps) => {
|
||||
const dayStocks = portfolio?.filter(s => s.strategy_type === 'daytrading') ?? []
|
||||
const swingStocks = portfolio?.filter(s => s.strategy_type !== 'daytrading') ?? []
|
||||
@@ -197,6 +214,7 @@ const PortfolioPanel = React.memo(({
|
||||
onSelectStock={onSelectStock}
|
||||
onRefetchAiAnalysis={onRefetchAiAnalysis}
|
||||
onSell={onSell}
|
||||
sellDisabled={sellDisabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -224,7 +242,11 @@ const PortfolioPanel = React.memo(({
|
||||
|
||||
{/* List */}
|
||||
<div className="divide-y divide-[#0f1e2e] overflow-y-auto custom-scrollbar flex-1">
|
||||
{total > 0 ? (
|
||||
{accountSetupRequired ? (
|
||||
<div className="h-full flex flex-col items-center justify-center p-3">
|
||||
<AccountSetupNotice message={accountSetupMessage} compact />
|
||||
</div>
|
||||
) : total > 0 ? (
|
||||
<>
|
||||
{renderGroup(dayStocks, '데이트레이딩', <Clock className="w-2.5 h-2.5 text-[#f97316]" />, 'text-[#f97316]')}
|
||||
{renderGroup(swingStocks, '스윙', <TrendingUp className="w-2.5 h-2.5 text-[#a78bfa]" />, 'text-[#a78bfa]')}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
*/
|
||||
import { AlertTriangle, TrendingUp, TrendingDown, Wallet, BarChart3, Calendar, ArrowUpDown } from 'lucide-react';
|
||||
import { PortfolioSummary as PortfolioSummaryType } from '../types/trading';
|
||||
import AccountSetupNotice from './AccountSetupNotice';
|
||||
|
||||
/** 당일 매매 통계 인터페이스 (Dashboard의 useMemo에서 계산) */
|
||||
interface TodayTradeStats {
|
||||
@@ -38,6 +39,10 @@ interface PortfolioSummaryProps {
|
||||
error: string | null | undefined;
|
||||
/** 당일 매매 통계 */
|
||||
todayTradeStats?: TodayTradeStats | null;
|
||||
/** true면 KIS 계좌 미등록/미검증 — error/스켈레톤 대신 계좌 설정 안내 표시 */
|
||||
accountSetupRequired?: boolean;
|
||||
/** 계좌 미등록/미검증 안내 문구 (accountSetupRequired=true일 때만 사용) */
|
||||
accountSetupMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +55,11 @@ interface PortfolioSummaryProps {
|
||||
* 에러 상태: 빨간 경고 배너 표시
|
||||
* 로딩 상태: 스켈레톤 애니메이션 (animate-pulse)
|
||||
*/
|
||||
export const PortfolioSummary = ({ summary, error, todayTradeStats }: PortfolioSummaryProps) => {
|
||||
export const PortfolioSummary = ({ summary, error, todayTradeStats, accountSetupRequired, accountSetupMessage }: PortfolioSummaryProps) => {
|
||||
if (accountSetupRequired) {
|
||||
return <AccountSetupNotice message={accountSetupMessage} />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-red-400 text-xs px-3 py-1.5 bg-[#1a0808] border border-red-900/40 rounded mb-3">
|
||||
|
||||
@@ -32,6 +32,8 @@ interface RecommendationItemProps {
|
||||
isPending: boolean
|
||||
livePrices: Record<string, number> | undefined
|
||||
onBuy: (stockCode: string, stockName: string) => Promise<void>
|
||||
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
buyDisabled?: boolean
|
||||
}
|
||||
|
||||
const RecommendationItem = React.memo(({
|
||||
@@ -45,6 +47,7 @@ const RecommendationItem = React.memo(({
|
||||
isPending,
|
||||
livePrices,
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
}: RecommendationItemProps) => {
|
||||
const changeRate = stock.change_rate || 0
|
||||
const isSelected = selectedStock === stock.stock_code
|
||||
@@ -53,13 +56,15 @@ const RecommendationItem = React.memo(({
|
||||
|
||||
const handleBuyClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation()
|
||||
if (buying) return
|
||||
if (buying || buyDisabled) return
|
||||
if (!window.confirm(`${stock.stock_name} 시장가 매수할까요?\n(수량: 총자산 × 종목당 최대비중으로 자동 산출)`)) return
|
||||
setBuying(true)
|
||||
try {
|
||||
await onBuy(stock.stock_code, stock.stock_name)
|
||||
} catch (error: unknown) {
|
||||
const err = error as any
|
||||
// 계좌 미등록/미검증(400/423)은 Dashboard 상단 인라인 안내로 처리 — alert 생략
|
||||
if (err?.isAccountSetupRequired) return
|
||||
alert(`매수 실패: ${err.response?.data?.detail || err.message}`)
|
||||
} finally {
|
||||
setBuying(false)
|
||||
@@ -135,7 +140,7 @@ const RecommendationItem = React.memo(({
|
||||
<div className="px-3 pb-1.5 pl-8">
|
||||
<button
|
||||
onClick={handleBuyClick}
|
||||
disabled={buying}
|
||||
disabled={buying || buyDisabled}
|
||||
className="w-full py-0.5 text-[9px] font-mono text-[#3a5570] hover:text-[#e84444] border border-[#152035] hover:border-[#e84444]/40 rounded-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{buying ? '매수 주문 중...' : '매수 (시장가)'}
|
||||
@@ -170,6 +175,8 @@ interface RecommendationPanelProps {
|
||||
livePrices: Record<string, number> | undefined
|
||||
/** 수동 매수 콜백 */
|
||||
onBuy: (stockCode: string, stockName: string) => Promise<void>
|
||||
/** true면 매수 버튼 비활성화 (예: 사용자 모드 확정 전) */
|
||||
buyDisabled?: boolean
|
||||
}
|
||||
|
||||
const RecommendationPanel = React.memo(({
|
||||
@@ -183,6 +190,7 @@ const RecommendationPanel = React.memo(({
|
||||
pendingBuyStockCodes,
|
||||
livePrices,
|
||||
onBuy,
|
||||
buyDisabled,
|
||||
}: RecommendationPanelProps) => {
|
||||
return (
|
||||
<div className="bg-[#0c1522] border border-[#152035] rounded overflow-hidden flex flex-col h-1/2">
|
||||
@@ -242,6 +250,7 @@ const RecommendationPanel = React.memo(({
|
||||
isPending={isPending}
|
||||
livePrices={livePrices}
|
||||
onBuy={onBuy}
|
||||
buyDisabled={buyDisabled}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user