사용자별 격리 시스템 + 사용자 관리 + 라이브 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
+18 -1
View File
@@ -15,9 +15,10 @@ JWT_EXP_HOURS = 24 * 7
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def create_token(username: str, role: str) -> str:
def create_token(user_id: int, username: str, role: str) -> str:
payload = {
"sub": username,
"uid": int(user_id),
"role": role,
"exp": datetime.utcnow() + timedelta(hours=JWT_EXP_HOURS),
"iat": datetime.utcnow(),
@@ -25,6 +26,22 @@ def create_token(username: str, role: str) -> str:
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
def get_uid(payload: dict) -> int:
"""JWT payload 에서 user_id 추출. 옛 토큰 호환 — uid 없으면 username 으로 lookup."""
if "uid" in payload:
return int(payload["uid"])
# 옛 토큰 fallback
try:
import users_db
username = payload.get("sub")
for u in users_db.list_users():
if u.get("username") == username:
return int(u["id"])
except Exception:
pass
return 0
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
+14 -2
View File
@@ -32,8 +32,20 @@ def on_startup():
trades_db.init_db()
exchange_keys.init_db()
users_db.init_db()
# alert_symbol DB 에서 동기화
alert_state.alert_symbol = settings_db.get("alert_symbol", "BTCUSDT")
# 모든 기존 사용자에 default settings 시드 (없으면)
for u in users_db.list_users():
settings_db.ensure_user_defaults(u["id"])
# admin (id=1) 의 글로벌 settings 를 그 user_settings 로 복사 (마이그레이션)
try:
admin_us = settings_db.all_settings(user_id=1)
if not admin_us.get("telegram_token"):
globals_dict = settings_db.all_settings()
for k, v in globals_dict.items():
if not admin_us.get(k):
settings_db.set_value(k, v, user_id=1)
print("[migration] admin (id=1) 으로 글로벌 settings 복사")
except Exception as e:
print(f"[migration] settings 마이그레이션 실패: {e}")
# 백그라운드 알림 / 일일 리포트 시작
core_logic.start_background_threads()
print("[FastAPI] 모든 모듈 init OK + 백그라운드 스레드 시작")
+1 -1
View File
@@ -22,7 +22,7 @@ def login(body: LoginIn):
user = users_db.authenticate(body.username.strip(), body.password)
if not user:
raise HTTPException(status_code=401, detail="아이디 또는 비밀번호가 올바르지 않습니다.")
token = create_token(user["username"], user.get("role", "user"))
token = create_token(user["id"], user["username"], user.get("role", "user"))
# datetime 직렬화 위해 string 변환
user_safe = {
"id": user.get("id"),
+11 -9
View File
@@ -3,14 +3,15 @@ from pydantic import BaseModel
from typing import Dict, Any
import exchange_keys
import exchange_adapters
from ..auth import require_user
from ..auth import require_user, get_uid
router = APIRouter()
@router.get("")
def get_config(_: dict = Depends(require_user)):
return exchange_keys.automation_all()
def get_config(payload: dict = Depends(require_user)):
uid = get_uid(payload)
return exchange_keys.automation_all(uid)
class UpdateIn(BaseModel):
@@ -18,20 +19,21 @@ class UpdateIn(BaseModel):
@router.put("")
def update_config(body: UpdateIn, _: dict = Depends(require_user)):
def update_config(body: UpdateIn, payload: dict = Depends(require_user)):
uid = get_uid(payload)
for k, v in body.values.items():
exchange_keys.automation_set(k, v)
exchange_keys.automation_set(k, v, uid)
return {"ok": True, "saved": len(body.values)}
@router.post("/test/balance")
def test_balance(_: dict = Depends(require_user)):
"""활성 키로 DryRun 어댑터 호출 — 동작 검증."""
cfg = exchange_keys.automation_all()
def test_balance(payload: dict = Depends(require_user)):
uid = get_uid(payload)
cfg = exchange_keys.automation_all(uid)
cred_id = cfg.get("active_credential", "")
if not cred_id:
return {"ok": False, "error": "활성 키 미선택"}
cred = exchange_keys.get_credential(int(cred_id))
cred = exchange_keys.get_credential(int(cred_id), uid)
adapter = exchange_adapters.make_adapter(cred, dry_run=True)
bal = adapter.get_balance("USDT")
return {"ok": True, "balance": bal, "exchange": adapter.exchange}
+13 -10
View File
@@ -2,8 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import exchange_keys
import exchange_adapters
from ..auth import require_user
from ..auth import require_user, get_uid
router = APIRouter()
@@ -36,14 +35,16 @@ def _serialize_cred(c):
@router.get("/credentials")
def list_creds(_: dict = Depends(require_user)):
return [_serialize_cred(c) for c in exchange_keys.list_credentials()]
def list_creds(payload: dict = Depends(require_user)):
uid = get_uid(payload)
return [_serialize_cred(c) for c in exchange_keys.list_credentials(uid)]
@router.post("/credentials")
def add_cred(body: CredIn, _: dict = Depends(require_user)):
def add_cred(body: CredIn, payload: dict = Depends(require_user)):
uid = get_uid(payload)
cid = exchange_keys.add_credential(
body.exchange, body.label, body.api_key, body.api_secret,
uid, body.exchange, body.label, body.api_key, body.api_secret,
body.passphrase, body.testnet, True,
)
if not cid:
@@ -52,18 +53,20 @@ def add_cred(body: CredIn, _: dict = Depends(require_user)):
@router.put("/credentials/{cred_id}")
def update_cred(cred_id: int, body: CredUpdateIn, _: dict = Depends(require_user)):
def update_cred(cred_id: int, body: CredUpdateIn, payload: dict = Depends(require_user)):
uid = get_uid(payload)
fields = {k: v for k, v in body.dict().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="변경 항목 없음")
if not exchange_keys.update_credential(cred_id, **fields):
if not exchange_keys.update_credential(cred_id, uid, **fields):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}
@router.delete("/credentials/{cred_id}")
def delete_cred(cred_id: int, _: dict = Depends(require_user)):
if not exchange_keys.delete_credential(cred_id):
def delete_cred(cred_id: int, payload: dict = Depends(require_user)):
uid = get_uid(payload)
if not exchange_keys.delete_credential(cred_id, uid):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}
+10 -9
View File
@@ -4,15 +4,15 @@ from typing import Dict, Any
import settings_db
import alert_state
from ..auth import require_user
from ..auth import require_user, get_uid
router = APIRouter()
@router.get("")
def get_all(_: dict = Depends(require_user)):
safe = settings_db.all_settings()
return safe
def get_all(payload: dict = Depends(require_user)):
uid = get_uid(payload)
return settings_db.all_settings(user_id=uid)
class UpdateIn(BaseModel):
@@ -20,11 +20,12 @@ class UpdateIn(BaseModel):
@router.put("")
def update_settings(body: UpdateIn, _: dict = Depends(require_user)):
def update_settings(body: UpdateIn, payload: dict = Depends(require_user)):
uid = get_uid(payload)
for k, v in body.values.items():
settings_db.set_value(k, v)
# alert symbol 동기화
sym = settings_db.get("alert_symbol", "BTCUSDT")
settings_db.set_value(k, v, user_id=uid)
# 사용자별 alert symbol 갱신
sym = settings_db.get("alert_symbol", "BTCUSDT", user_id=uid)
with alert_state.alert_lock:
alert_state.alert_symbol = sym
alert_state.alert_symbol_by_user[uid] = sym
return {"ok": True, "saved": len(body.values)}
+43 -5
View File
@@ -1,6 +1,7 @@
import requests
from fastapi import APIRouter, Depends, Query
import trades_db
from ..auth import require_user
from ..auth import require_user, get_uid
router = APIRouter()
@@ -16,11 +17,48 @@ def _serialize(rows):
return out
def _get_all_tickers() -> dict:
try:
r = requests.get("https://fapi.binance.com/fapi/v1/ticker/price", timeout=5, verify=False)
return {t["symbol"]: float(t["price"]) for t in r.json()}
except Exception as e:
print(f"[trades_route] tickers fetch fail: {e}")
return {}
def _enrich_open_with_live(rows: list) -> list:
has_open = any(r.get("status") == "open" for r in rows)
if not has_open:
return rows
tickers = _get_all_tickers()
if not tickers:
return rows
for r in rows:
if r.get("status") != "open":
continue
sym = r.get("symbol")
cur = tickers.get(sym)
if cur is None or not r.get("entry_price"):
continue
entry = float(r["entry_price"])
if r.get("direction") == "long":
pnl = (cur - entry) / entry * 100.0
else:
pnl = (entry - cur) / entry * 100.0
r["current_price"] = cur
r["live_pnl_pct"] = round(pnl, 4)
return rows
@router.get("")
def list_trades(limit: int = Query(500, ge=1, le=2000), _: dict = Depends(require_user)):
return _serialize(trades_db.fetch_trades(limit=limit))
def list_trades(limit: int = Query(500, ge=1, le=2000), payload: dict = Depends(require_user)):
uid = get_uid(payload)
rows = _serialize(trades_db.fetch_trades(uid, limit=limit))
rows = _enrich_open_with_live(rows)
return rows
@router.get("/signals")
def list_signal_events(limit: int = Query(1000, ge=1, le=5000), _: dict = Depends(require_user)):
return _serialize(trades_db.fetch_signal_events(limit=limit))
def list_signal_events(limit: int = Query(1000, ge=1, le=5000), payload: dict = Depends(require_user)):
uid = get_uid(payload)
return _serialize(trades_db.fetch_signal_events(uid, limit=limit))
+85 -10
View File
@@ -1,18 +1,93 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from typing import Optional
import users_db
from ..auth import require_user, require_admin
router = APIRouter()
def _serialize(r):
return {
"id": r["id"], "username": r["username"], "role": r["role"],
"created_at": str(r["created_at"]) if r.get("created_at") else None,
"last_login_at": str(r["last_login_at"]) if r.get("last_login_at") else None,
}
@router.get("")
def list_users(_: dict = Depends(require_admin)):
rows = users_db.list_users()
return [
{
"id": r["id"], "username": r["username"], "role": r["role"],
"created_at": str(r["created_at"]) if r.get("created_at") else None,
"last_login_at": str(r["last_login_at"]) if r.get("last_login_at") else None,
}
for r in rows
]
return [_serialize(r) for r in users_db.list_users()]
class CreateUserIn(BaseModel):
username: str
password: str
role: str = "user"
@router.post("")
def create_user(body: CreateUserIn, _: dict = Depends(require_admin)):
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="role 은 admin / user")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="비밀번호 6자 이상")
if not body.username.strip():
raise HTTPException(status_code=400, detail="username 필수")
uid = users_db.create_user(body.username.strip(), body.password, body.role)
if uid is None:
raise HTTPException(status_code=400, detail="생성 실패 (이미 존재할 수 있음)")
return {"id": uid, "username": body.username.strip(), "role": body.role}
@router.delete("/{user_id}")
def delete_user(user_id: int, payload: dict = Depends(require_admin)):
me = payload.get("sub")
# 본인 삭제 방지
target = next((u for u in users_db.list_users() if u["id"] == user_id), None)
if target and target["username"] == me:
raise HTTPException(status_code=400, detail="본인 계정은 삭제 불가")
# admin 이 단 한명이면 삭제 방지
if target and target["role"] == "admin":
admin_count = sum(1 for u in users_db.list_users() if u["role"] == "admin")
if admin_count <= 1:
raise HTTPException(status_code=400, detail="마지막 admin 은 삭제 불가")
if not users_db.delete_user(user_id):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}
class ResetPasswordIn(BaseModel):
new_password: str
@router.put("/{user_id}/password")
def reset_password(user_id: int, body: ResetPasswordIn, _: dict = Depends(require_admin)):
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="비밀번호 6자 이상")
if not users_db.admin_reset_password(user_id, body.new_password):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}
class SetRoleIn(BaseModel):
role: str
@router.put("/{user_id}/role")
def set_role(user_id: int, body: SetRoleIn, payload: dict = Depends(require_admin)):
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="role 은 admin / user")
me = payload.get("sub")
target = next((u for u in users_db.list_users() if u["id"] == user_id), None)
# 본인 role 강등 방지
if target and target["username"] == me and body.role != "admin":
raise HTTPException(status_code=400, detail="본인 role 변경 불가")
# 마지막 admin 강등 방지
if target and target["role"] == "admin" and body.role != "admin":
admin_count = sum(1 for u in users_db.list_users() if u["role"] == "admin")
if admin_count <= 1:
raise HTTPException(status_code=400, detail="마지막 admin role 변경 불가")
if not users_db.set_role(user_id, body.role):
raise HTTPException(status_code=404, detail="not found")
return {"ok": True}