사용자별 격리 시스템 + 사용자 관리 + 라이브 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
+100 -46
View File
@@ -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 []