사용자별 격리 시스템 + 사용자 관리 + 라이브 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:
+18
-1
@@ -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
@@ -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 + 백그라운드 스레드 시작")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user