사용자별 격리 시스템 + 사용자 관리 + 라이브 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:
chpark
2026-05-22 12:14:23 +09:00
parent c330647453
commit d16456cb92
20 changed files with 934 additions and 344 deletions
+82 -24
View File
@@ -12,7 +12,7 @@ SQLite 의 thread-safe 모드 (`check_same_thread=False`) 로 연다.
import os
import sqlite3
import threading
from typing import Any, Optional
from typing import Any, Optional, List
DB_PATH = os.environ.get("SETTINGS_DB_PATH", "/app/data/settings.db")
_lock = threading.RLock()
@@ -25,6 +25,7 @@ def _get_conn() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
_conn = sqlite3.connect(DB_PATH, check_same_thread=False, isolation_level=None)
_conn.execute("PRAGMA journal_mode=WAL")
# 글로벌 settings 테이블 (옛 호환 + 기본값 시드용)
_conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -34,6 +35,19 @@ def _get_conn() -> sqlite3.Connection:
)
"""
)
# 사용자별 settings 테이블
_conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, key)
)
"""
)
_conn.execute("CREATE INDEX IF NOT EXISTS idx_us_user ON user_settings(user_id)")
return _conn
@@ -74,7 +88,7 @@ DEFAULTS = {
def init_db_with_env_defaults():
"""최초 기동 시 .env 값을 DB 기본값으로 복사. 이미 존재하는 키는 건드리지 않음."""
"""최초 기동 시 글로벌 settings 의 .env 값 시드 (옛 호환용)."""
with _lock:
conn = _get_conn()
for k, default in DEFAULTS.items():
@@ -88,15 +102,33 @@ def init_db_with_env_defaults():
}
if k in env_map:
seed = os.environ.get(env_map[k], default) or default
conn.execute(
"INSERT INTO settings(key, value) VALUES (?, ?)",
(k, seed),
)
conn.execute("INSERT INTO settings(key, value) VALUES (?, ?)", (k, seed),)
def get(key: str, default: Any = None) -> str:
def ensure_user_defaults(user_id: int):
"""사용자가 처음 로그인 / 생성될 때 호출. 글로벌 DEFAULTS 를 그 사용자 키로 복사."""
if not user_id:
return
with _lock:
conn = _get_conn()
for k, default in DEFAULTS.items():
cur = conn.execute("SELECT value FROM user_settings WHERE user_id=? AND key=?", (user_id, k))
if cur.fetchone() is None:
conn.execute(
"INSERT INTO user_settings(user_id, key, value) VALUES (?, ?, ?)",
(user_id, k, default),
)
def get(key: str, default: Any = None, user_id: Optional[int] = None) -> str:
"""user_id 가 있으면 그 사용자 값. 없으면 글로벌 (옛 호환). 사용자 값 없으면 DEFAULTS."""
with _lock:
conn = _get_conn()
if user_id:
cur = conn.execute("SELECT value FROM user_settings WHERE user_id=? AND key=?", (user_id, key))
row = cur.fetchone()
if row is not None:
return row[0]
cur = conn.execute("SELECT value FROM settings WHERE key=?", (key,))
row = cur.fetchone()
if row is None:
@@ -104,46 +136,72 @@ def get(key: str, default: Any = None) -> str:
return row[0]
def get_int(key: str, default: int = 0) -> int:
def get_int(key: str, default: int = 0, user_id: Optional[int] = None) -> int:
try:
return int(get(key, default))
return int(get(key, default, user_id))
except (TypeError, ValueError):
return default
def get_float(key: str, default: float = 0.0) -> float:
def get_float(key: str, default: float = 0.0, user_id: Optional[int] = None) -> float:
try:
return float(get(key, default))
return float(get(key, default, user_id))
except (TypeError, ValueError):
return default
def get_bool(key: str, default: bool = False) -> bool:
v = get(key, "1" if default else "0")
def get_bool(key: str, default: bool = False, user_id: Optional[int] = None) -> bool:
v = get(key, "1" if default else "0", user_id)
return str(v).strip().lower() in ("1", "true", "yes", "on")
def get_list(key: str, default=None, sep: str = ",") -> list:
v = get(key, "")
def get_list(key: str, default=None, sep: str = ",", user_id: Optional[int] = None) -> list:
v = get(key, "", user_id)
if not v:
return list(default or [])
return [s.strip() for s in v.split(sep) if s.strip()]
def set_value(key: str, value: Any):
def set_value(key: str, value: Any, user_id: Optional[int] = None):
with _lock:
conn = _get_conn()
conn.execute(
"""
INSERT INTO settings(key, value, updated_at) VALUES(?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, str(value)),
)
if user_id:
conn.execute(
"""
INSERT INTO user_settings(user_id, key, value, updated_at)
VALUES(?, ?, ?, datetime('now'))
ON CONFLICT(user_id, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(user_id, key, str(value)),
)
else:
conn.execute(
"""
INSERT INTO settings(key, value, updated_at) VALUES(?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
""",
(key, str(value)),
)
def all_settings() -> dict:
def all_settings(user_id: Optional[int] = None) -> dict:
"""user_id 가 있으면 그 사용자의 설정 (없는 키는 글로벌 fallback + DEFAULTS).
없으면 글로벌 settings 만 반환 (옛 호환)."""
with _lock:
conn = _get_conn()
if user_id:
ensure_user_defaults(user_id)
cur = conn.execute("SELECT key, value FROM user_settings WHERE user_id=? ORDER BY key", (user_id,))
return dict(cur.fetchall())
cur = conn.execute("SELECT key, value FROM settings ORDER BY key")
return dict(cur.fetchall())
def list_user_ids_with_alerts_enabled() -> list:
"""모든 사용자 중 alert_enabled=1 인 user_id 목록. 알림 스레드용."""
with _lock:
conn = _get_conn()
cur = conn.execute(
"SELECT user_id FROM user_settings WHERE key='alert_enabled' AND value='1'"
)
return [r[0] for r in cur.fetchall()]