Files
HyperNode-AI/app/api.py
T

312 lines
11 KiB
Python

"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.database import get_db
from app.deps import AdminUser, CurrentUser
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, User, UserRole, WithdrawalLog, WithdrawalStatus
from app.money import money
from app.schemas import (
AdminUserOut,
BalanceOut,
ContributionOut,
DashboardOut,
HeartbeatIn,
HeartbeatOut,
LoginIn,
MeNodeCreate,
MeWithdrawalCreate,
NodeCreate,
NodeOut,
NodeView,
RegisterIn,
SettleOut,
SummaryOut,
TaskOut,
TaskReportIn,
TokenOut,
UserCreate,
UserOut,
WithdrawalConfirm,
WithdrawalCreate,
WithdrawalOut,
WithdrawalReject,
WithdrawalView,
YieldOut,
)
from app.security import create_access_token
from app.services.auth import authenticate, register_account
from app.services.common import get_node_by_sn, get_user_by_wallet
from app.services.heartbeat import record_heartbeat
from app.services.portal import (
authorized_amount,
live_nodes,
node_counts,
pending_withdrawal_amount,
settled_points,
user_rows,
)
from app.services.registry import register_node, register_user
from app.services.tasks import list_pending_tasks, report_task
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
from app.services.yield_engine import compute_node_yield, list_node_yields, settle_yield_date
router = APIRouter(prefix="/api/v1")
Db = Annotated[Session, Depends(get_db)]
@router.post("/auth/register", response_model=TokenOut)
def register(body: RegisterIn, db: Db) -> TokenOut:
user = register_account(db, body.wallet_address, body.username, body.password)
return _token(user)
@router.post("/auth/login", response_model=TokenOut)
def login(body: LoginIn, db: Db) -> TokenOut:
user = authenticate(db, body.wallet_address, body.username, body.password)
return _token(user)
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser) -> User:
return user
@router.get("/me/dashboard", response_model=DashboardOut)
def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
nodes = live_nodes(db, user.id)
withdrawals = db.scalars(
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
).all()
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
return DashboardOut(
wallet_address=user.wallet_address,
username=user.username,
role=user.role,
available_points=available_balance(db, user.id),
settled_points=settled_points(db, user.id),
today_points=today,
nodes=[NodeView.model_validate(item) for item in nodes],
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
)
@router.post("/me/nodes", response_model=NodeOut)
def create_my_node(body: MeNodeCreate, user: CurrentUser, db: Db) -> NodeOut:
return register_node(db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url)
@router.post("/me/withdrawals", response_model=WithdrawalOut)
def create_my_withdrawal(body: MeWithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
row, remaining = request_withdrawal(db, user.wallet_address, body.amount, body.asset_symbol)
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
@router.get("/admin/summary", response_model=SummaryOut)
def admin_summary(_: AdminUser, db: Db) -> SummaryOut:
return _summary(db)
@router.get("/admin/users", response_model=list[AdminUserOut])
def admin_users(_: AdminUser, db: Db) -> list[dict]:
return user_rows(db)
@router.get("/admin/nodes", response_model=list[NodeView])
def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
return [NodeView.model_validate(item) for item in live_nodes(db)]
@router.get("/admin/withdrawals", response_model=list[WithdrawalView])
def admin_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
@router.post("/admin/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
def admin_confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
@router.post("/admin/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
def admin_reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
return reject_withdrawal(db, withdrawal_id, body.note)
@router.get("/summary", response_model=SummaryOut)
def summary(_: AdminUser, db: Db) -> SummaryOut:
return _summary(db)
@router.get("/users", response_model=list[UserOut])
def list_users(_: AdminUser, db: Db) -> list[User]:
return list(db.scalars(select(User).order_by(User.id.desc())).all())
@router.post("/users", response_model=UserOut)
def create_user(body: UserCreate, db: Db) -> UserOut:
return register_user(db, body.wallet_address)
@router.get("/nodes", response_model=list[NodeView])
def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
return [NodeView.model_validate(item) for item in live_nodes(db)]
@router.post("/nodes", response_model=NodeOut)
def create_node(body: NodeCreate, user: CurrentUser, db: Db) -> NodeOut:
_must_own_wallet(user, body.wallet_address)
return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
@router.get("/nodes/{sn}", response_model=NodeOut)
def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
return _owned_node(db, user, sn)
@router.post("/node/heartbeat", response_model=HeartbeatOut)
def heartbeat(body: HeartbeatIn, db: Db) -> HeartbeatOut:
node = record_heartbeat(db, body.sn)
return HeartbeatOut(
sn=node.sn,
status=node.status,
last_heartbeat_at=node.last_heartbeat_at,
server_time=node.last_heartbeat_at,
)
@router.get("/node/{sn}/tasks", response_model=list[TaskOut])
def pending_tasks(sn: str, db: Db) -> list[TaskOut]:
return [_task_out(task) for task in list_pending_tasks(db, sn)]
@router.post("/node/task/report", response_model=TaskOut)
def report(body: TaskReportIn, db: Db) -> TaskOut:
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce))
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
node = _owned_node(db, user, sn)
computed = compute_node_yield(db, node, yield_date)
return ContributionOut(sn=node.sn, **computed.__dict__)
@router.post("/yield/settle", response_model=SettleOut)
def settle(_: AdminUser, db: Db, yield_date: date | None = None) -> SettleOut:
target = yield_date or (utcnow().date() - timedelta(days=1))
summary = settle_yield_date(db, target)
return SettleOut(
yield_date=summary.yield_date,
created=summary.created,
skipped=summary.skipped,
total_points=summary.total_points,
is_partial=summary.is_partial,
)
@router.get("/nodes/{sn}/yields", response_model=list[YieldOut])
def yields(sn: str, user: CurrentUser, db: Db) -> list[YieldOut]:
_owned_node(db, user, sn)
return list_node_yields(db, sn)
@router.get("/withdrawals", response_model=list[WithdrawalView])
def list_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
@router.get("/users/{wallet_address}/balance", response_model=BalanceOut)
def balance(wallet_address: str, user: CurrentUser, db: Db) -> BalanceOut:
_must_own_wallet(user, wallet_address)
owner = get_user_by_wallet(db, wallet_address)
return BalanceOut(wallet_address=owner.wallet_address, available_points=available_balance(db, owner.id))
@router.post("/withdrawals", response_model=WithdrawalOut)
def create_withdrawal(body: WithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
_must_own_wallet(user, body.wallet_address)
row, remaining = request_withdrawal(db, body.wallet_address, body.amount, body.asset_symbol)
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
@router.post("/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
def confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
@router.post("/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
def reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
return reject_withdrawal(db, withdrawal_id, body.note)
def _summary(db: Session) -> SummaryOut:
users = db.scalar(select(func.count()).select_from(User)) or 0
nodes, online = node_counts(db)
return SummaryOut(
users=users,
nodes=nodes,
online=online,
offline=nodes - online,
authorized_amount=authorized_amount(db),
settled_points=settled_points(db),
pending_withdrawals=pending_withdrawal_amount(db),
)
def _token(user: User) -> TokenOut:
return TokenOut(
access_token=create_access_token(user),
role=user.role,
wallet_address=user.wallet_address,
username=user.username,
)
def _must_own_wallet(user: User, wallet_address: str) -> None:
if user.role != UserRole.ADMIN and user.wallet_address != wallet_address.strip():
raise AppError("只能操作自己的钱包", 403)
def _owned_node(db: Session, user: User, sn: str) -> ComputeNode:
node = get_node_by_sn(db, sn)
if user.role != UserRole.ADMIN and node.user_id != user.id:
raise AppError("无权查看该节点", 403)
return node
def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
pending_first = case((WithdrawalLog.status == WithdrawalStatus.PENDING, 0), else_=1)
return list(
db.scalars(
select(WithdrawalLog)
.options(selectinload(WithdrawalLog.user))
.order_by(pending_first, WithdrawalLog.id.desc())
).all()
)
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
return WithdrawalView(
**WithdrawalOut.model_validate(row).model_dump(),
wallet_address=row.user.wallet_address,
)
def _task_out(task: ComputeTask) -> TaskOut:
return TaskOut(
task_id=task.id,
task_type=task.task_type,
payload=task.payload,
status=task.status,
dispatched_at=task.dispatched_at,
deadline_at=task.deadline_at,
)