사용자별 격리 시스템 + 사용자 관리 + 라이브 PnL%
# 사용자별 격리 - JWT 토큰에 uid 추가 (auth.get_uid 헬퍼) - PostgreSQL — exchange_credentials/automation_config/trades/signal_events 에 user_id BIGINT - SQLite user_settings 테이블 신설 (글로벌 settings 는 옛 호환) - 모든 DB 함수 시그니처에 user_id 인자 추가 — 다른 사용자 데이터 절대 접근 불가 - alert_state — 모든 dict key 가 (user_id, ...) tuple 로 계층화 - core_logic alert_loop — 활성 사용자 순회 + 각자 settings/symbol/텔레그램 적용 - ensure_user_defaults() / ensure_user_automation() — 첫 사용 시 자동 시드 # 사용자 관리 (admin only) - users_db: delete_user / admin_reset_password / set_role - /api/users POST DELETE PUT password PUT role (본인 강등 / 마지막 admin 보호) - /admin/users 페이지 — 등록/삭제/role 토글/비번 reset 모달 - 사이드바 adminOnly 필터 — admin role 만 메뉴 노출 # 대시보드 개선 - 모바일 / 범례 토글 (모바일 60 캔들, 데스크톱 200) - 트레이드 이력: open 트레이드 실시간 PnL% (Binance ticker 호출 + 방향별 계산) - 메트릭 카드 분리 (실거래 vs 실시간 open) # 안정성 - api.ts: error.detail array/object 안전 처리 ([object Object] 방지) - Chart.tsx: Plotly yaxis title 객체 형태 + 모바일 height 동적 조정 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+100
-46
@@ -110,42 +110,74 @@ def init_db():
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
# 사용자별 격리 — user_id 컬럼 추가 (없으면)
|
||||
cur.execute("ALTER TABLE exchange_credentials ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_excred_user ON exchange_credentials(user_id)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_excred_exchange ON exchange_credentials(exchange)")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS automation_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
# 기본 자동매매 설정 (없으면 시드)
|
||||
_seed_automation(cur)
|
||||
# automation_config 마이그레이션 — 옛 (key PRIMARY KEY) → (user_id, key)
|
||||
cur.execute("ALTER TABLE automation_config ADD COLUMN IF NOT EXISTS user_id BIGINT NOT NULL DEFAULT 1")
|
||||
# 옛 PRIMARY KEY 제거 후 (user_id, key) composite PK
|
||||
cur.execute("""
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname='automation_config_pkey') THEN
|
||||
ALTER TABLE automation_config DROP CONSTRAINT automation_config_pkey;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='automation_config_user_key') THEN
|
||||
ALTER TABLE automation_config ADD CONSTRAINT automation_config_user_key UNIQUE (user_id, key);
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
_seed_automation_default()
|
||||
_init_done = True
|
||||
print("[exchange_keys] init OK")
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] init 실패: {e}")
|
||||
|
||||
|
||||
def _seed_automation_default():
|
||||
"""글로벌 default 시드 — 새 사용자가 처음 자동매매 페이지 열면 ensure_user_automation() 호출."""
|
||||
pass
|
||||
|
||||
|
||||
AUTOMATION_DEFAULTS = {
|
||||
"enabled": "0", # 자동매매 ON/OFF (글로벌 킬스위치)
|
||||
"dry_run": "1", # 1 = 실제 주문 X, 시그널만 기록 (안전)
|
||||
"active_credential": "", # exchange_credentials.id (활성 키)
|
||||
"enabled": "0",
|
||||
"dry_run": "1",
|
||||
"active_credential": "",
|
||||
"leverage": "10",
|
||||
"position_size_pct": "1.0", # 잔고 대비 %
|
||||
"position_size_pct": "1.0",
|
||||
"max_open_trades": "3",
|
||||
"min_signal_score": "1", # 이 신호 수 이상일 때만 진입 (예: 강한 + 일반 동시)
|
||||
"allowed_directions": "long,short", # long_only / short_only / long,short
|
||||
"tp_pct": "0.0", # take profit (%, 0 = OFF)
|
||||
"min_signal_score": "1",
|
||||
"allowed_directions": "long,short",
|
||||
"tp_pct": "0.0",
|
||||
}
|
||||
|
||||
|
||||
def _seed_automation(cur):
|
||||
for k, v in AUTOMATION_DEFAULTS.items():
|
||||
cur.execute(
|
||||
"INSERT INTO automation_config(key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING",
|
||||
(k, v),
|
||||
)
|
||||
def ensure_user_automation(user_id: int):
|
||||
"""사용자가 처음 자동매매 페이지 열 때 default 시드."""
|
||||
if not _enabled() or not user_id:
|
||||
return
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for k, v in AUTOMATION_DEFAULTS.items():
|
||||
cur.execute(
|
||||
"INSERT INTO automation_config(user_id, key, value) VALUES (%s, %s, %s) "
|
||||
"ON CONFLICT (user_id, key) DO NOTHING",
|
||||
(user_id, k, v),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] ensure_user_automation 실패: {e}")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
@@ -186,8 +218,8 @@ def _mask(s: Optional[str]) -> str:
|
||||
SUPPORTED_EXCHANGES = ["binance", "bybit", "okx", "bitget", "upbit", "bithumb"]
|
||||
|
||||
|
||||
def list_credentials() -> List[Dict[str, Any]]:
|
||||
if not _enabled():
|
||||
def list_credentials(user_id: int) -> List[Dict[str, Any]]:
|
||||
if not _enabled() or not user_id:
|
||||
return []
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
@@ -198,7 +230,8 @@ def list_credentials() -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"SELECT id, exchange, label, testnet, enabled, created_at, updated_at, "
|
||||
"api_key_enc, api_secret_enc, passphrase_enc "
|
||||
"FROM exchange_credentials ORDER BY id DESC"
|
||||
"FROM exchange_credentials WHERE user_id=%s ORDER BY id DESC",
|
||||
(user_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for r in rows:
|
||||
@@ -212,9 +245,9 @@ def list_credentials() -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def get_credential(cred_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""복호화된 키를 그대로 반환. 자동매매 어댑터에서 호출."""
|
||||
if not _enabled():
|
||||
def get_credential(cred_id: int, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""user_id 소유 cred 만 반환 (다른 사용자 키 접근 차단)."""
|
||||
if not _enabled() or not user_id:
|
||||
return None
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
@@ -222,7 +255,7 @@ def get_credential(cred_id: int) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
try:
|
||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||
cur.execute("SELECT * FROM exchange_credentials WHERE id=%s", (cred_id,))
|
||||
cur.execute("SELECT * FROM exchange_credentials WHERE id=%s AND user_id=%s", (cred_id, user_id))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
@@ -235,9 +268,9 @@ def get_credential(cred_id: int) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def add_credential(exchange: str, label: str, api_key: str, api_secret: str,
|
||||
def add_credential(user_id: int, exchange: str, label: str, api_key: str, api_secret: str,
|
||||
passphrase: Optional[str] = None, testnet: bool = False, enabled: bool = True) -> Optional[int]:
|
||||
if not _enabled():
|
||||
if not _enabled() or not user_id:
|
||||
return None
|
||||
if not api_key or not api_secret:
|
||||
return None
|
||||
@@ -248,10 +281,10 @@ def add_credential(exchange: str, label: str, api_key: str, api_secret: str,
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO exchange_credentials(exchange, label, api_key_enc, api_secret_enc, "
|
||||
"passphrase_enc, testnet, enabled) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
"INSERT INTO exchange_credentials(user_id, exchange, label, api_key_enc, api_secret_enc, "
|
||||
"passphrase_enc, testnet, enabled) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
|
||||
(
|
||||
exchange, label or "",
|
||||
user_id, exchange, label or "",
|
||||
_encrypt(api_key), _encrypt(api_secret), _encrypt(passphrase),
|
||||
bool(testnet), bool(enabled),
|
||||
),
|
||||
@@ -262,8 +295,8 @@ def add_credential(exchange: str, label: str, api_key: str, api_secret: str,
|
||||
return None
|
||||
|
||||
|
||||
def update_credential(cred_id: int, **fields) -> bool:
|
||||
if not _enabled() or not fields:
|
||||
def update_credential(cred_id: int, user_id: int, **fields) -> bool:
|
||||
if not _enabled() or not user_id or not fields:
|
||||
return False
|
||||
set_parts = []
|
||||
values = []
|
||||
@@ -280,22 +313,25 @@ def update_credential(cred_id: int, **fields) -> bool:
|
||||
if not set_parts:
|
||||
return False
|
||||
set_parts.append("updated_at=now()")
|
||||
values.append(cred_id)
|
||||
values.extend([cred_id, user_id])
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"UPDATE exchange_credentials SET {', '.join(set_parts)} WHERE id=%s", tuple(values))
|
||||
cur.execute(
|
||||
f"UPDATE exchange_credentials SET {', '.join(set_parts)} WHERE id=%s AND user_id=%s",
|
||||
tuple(values),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] update_credential 실패: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def delete_credential(cred_id: int) -> bool:
|
||||
if not _enabled():
|
||||
def delete_credential(cred_id: int, user_id: int) -> bool:
|
||||
if not _enabled() or not user_id:
|
||||
return False
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
@@ -303,7 +339,7 @@ def delete_credential(cred_id: int) -> bool:
|
||||
return False
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("DELETE FROM exchange_credentials WHERE id=%s", (cred_id,))
|
||||
cur.execute("DELETE FROM exchange_credentials WHERE id=%s AND user_id=%s", (cred_id, user_id))
|
||||
return cur.rowcount > 0
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] delete_credential 실패: {e}")
|
||||
@@ -313,8 +349,8 @@ def delete_credential(cred_id: int) -> bool:
|
||||
# ──────────────────────────────────────────────
|
||||
# Automation config
|
||||
# ──────────────────────────────────────────────
|
||||
def automation_get(key: str, default: str = "") -> str:
|
||||
if not _enabled():
|
||||
def automation_get(key: str, user_id: int, default: str = "") -> str:
|
||||
if not _enabled() or not user_id:
|
||||
return AUTOMATION_DEFAULTS.get(key, default)
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
@@ -322,7 +358,7 @@ def automation_get(key: str, default: str = "") -> str:
|
||||
return AUTOMATION_DEFAULTS.get(key, default)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT value FROM automation_config WHERE key=%s", (key,))
|
||||
cur.execute("SELECT value FROM automation_config WHERE user_id=%s AND key=%s", (user_id, key))
|
||||
row = cur.fetchone()
|
||||
if row is None:
|
||||
return AUTOMATION_DEFAULTS.get(key, default)
|
||||
@@ -332,8 +368,8 @@ def automation_get(key: str, default: str = "") -> str:
|
||||
return AUTOMATION_DEFAULTS.get(key, default)
|
||||
|
||||
|
||||
def automation_set(key: str, value: Any) -> bool:
|
||||
if not _enabled():
|
||||
def automation_set(key: str, value: Any, user_id: int) -> bool:
|
||||
if not _enabled() or not user_id:
|
||||
return False
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
@@ -342,9 +378,9 @@ def automation_set(key: str, value: Any) -> bool:
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
"INSERT INTO automation_config(key, value) VALUES (%s, %s) "
|
||||
"ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()",
|
||||
(key, str(value)),
|
||||
"INSERT INTO automation_config(user_id, key, value) VALUES (%s, %s, %s) "
|
||||
"ON CONFLICT (user_id, key) DO UPDATE SET value=EXCLUDED.value, updated_at=now()",
|
||||
(user_id, key, str(value)),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -352,16 +388,17 @@ def automation_set(key: str, value: Any) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def automation_all() -> Dict[str, str]:
|
||||
if not _enabled():
|
||||
def automation_all(user_id: int) -> Dict[str, str]:
|
||||
if not _enabled() or not user_id:
|
||||
return dict(AUTOMATION_DEFAULTS)
|
||||
ensure_user_automation(user_id)
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return dict(AUTOMATION_DEFAULTS)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT key, value FROM automation_config")
|
||||
cur.execute("SELECT key, value FROM automation_config WHERE user_id=%s", (user_id,))
|
||||
rows = cur.fetchall()
|
||||
d = dict(AUTOMATION_DEFAULTS)
|
||||
d.update(dict(rows))
|
||||
@@ -369,3 +406,20 @@ def automation_all() -> Dict[str, str]:
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] automation_all 실패: {e}")
|
||||
return dict(AUTOMATION_DEFAULTS)
|
||||
|
||||
|
||||
def list_users_with_auto_enabled() -> List[int]:
|
||||
"""자동매매 활성 사용자 ID 리스트 (자동매매 워커용)."""
|
||||
if not _enabled():
|
||||
return []
|
||||
with _lock:
|
||||
conn = _get_conn()
|
||||
if conn is None:
|
||||
return []
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT user_id FROM automation_config WHERE key='enabled' AND value='1'")
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
except Exception as e:
|
||||
print(f"[exchange_keys] list_users_with_auto_enabled 실패: {e}")
|
||||
return []
|
||||
|
||||
Reference in New Issue
Block a user