Files
HyperNode-AI/app/api.py
T

538 lines
19 KiB
Python

"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
from datetime import date, timedelta
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import Response
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.config import settings
from app.database import get_db
from app.deps import AdminUser, CurrentUser
from app.errors import AppError
from app.models import (
ComputeNode,
ComputeTask,
DatacenterApplication,
IPWhitelist,
LicenseOrder,
User,
UserRole,
WithdrawalLog,
WithdrawalStatus,
)
from app.money import money
from app.schemas import (
AdminUserOut,
BalanceOut,
ContributionOut,
DashboardOut,
HeartbeatIn,
HeartbeatOut,
LoginIn,
MeNodeCreate,
OrderCreate,
OrderOut,
OrderView,
MeWithdrawalCreate,
NodeCreate,
NodeOut,
NodeView,
IPWhitelistCreate,
IPWhitelistOut,
BanToggle,
WhitelistToggle,
RegisterIn,
SettleOut,
SummaryOut,
AdminTaskOut,
TaskCreate,
TaskOut,
TaskReportIn,
TokenOut,
UserCreate,
UserOut,
WithdrawalConfirm,
WithdrawalCreate,
WithdrawalOut,
DatacenterApply,
DatacenterOut,
DatacenterView,
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.datacenter import approve_application, list_applications, reject_application, submit_application
from app.services.hardware import HardwareReport
from app.services.hardware import display_node_id, displayed_population
from app.services.heartbeat import assert_node_token, record_heartbeat, set_node_banned
from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
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 create_admin_tasks, list_admin_tasks, list_pending_tasks, report_task, result_preview
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, network_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("/network")
def network_population(db: Db) -> dict[str, int]:
nodes, online = node_counts(db)
return {"nodes": displayed_population(nodes), "online": displayed_population(online)}
@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")))
network_total, network_online = node_counts(db)
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],
orders=[_order_out(item) for item in list_orders(db, user.id)],
license_price=money(settings.license_price),
license_asset=settings.license_asset,
network_nodes=displayed_population(network_total),
network_online=displayed_population(network_online),
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
datacenter_applications=[_datacenter_out(item) for item in list_applications(db, user.id)],
)
@router.post("/me/orders", response_model=OrderOut)
def create_my_order(body: OrderCreate, user: CurrentUser, db: Db) -> OrderOut:
return _order_out(place_order(db, user.id, body.tx_id))
@router.get("/me/nodes/{sn}/client")
def download_client(sn: str, request: Request, user: CurrentUser, db: Db) -> Response:
node = _owned_node(db, user, sn)
if not node.node_token:
raise AppError("节点尚未开通,不能下载客户端", 403)
script = render_client(node.sn, node.node_token, str(request.base_url))
filename = f"hypernode-{node.sn}.py"
return Response(
content=script,
media_type="text/x-python; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/me/nodes", response_model=NodeOut)
def create_my_node(body: MeNodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
return register_node(
db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
)
@router.post("/me/datacenter-applications", response_model=DatacenterOut)
def create_datacenter_application(body: DatacenterApply, user: CurrentUser, db: Db) -> DatacenterOut:
return _datacenter_out(
submit_application(
db,
user.id,
body.contact,
body.node_count,
body.subnet_ip,
body.hardware_proof,
body.gpu_generation,
)
)
@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.post("/admin/nodes/{sn}/whitelist", response_model=NodeView)
def admin_toggle_node_whitelist(sn: str, body: WhitelistToggle, _: AdminUser, db: Db) -> NodeView:
node = get_node_by_sn(db, sn)
node.is_whitelist = body.is_whitelist
node.updated_at = utcnow()
db.flush()
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
return NodeView.model_validate(snapshot)
@router.post("/admin/nodes/{sn}/ban", response_model=NodeView)
def admin_ban_node(sn: str, body: BanToggle, _: AdminUser, db: Db) -> NodeView:
node = get_node_by_sn(db, sn)
set_node_banned(db, node, body.is_banned)
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
return NodeView.model_validate(snapshot)
@router.get("/admin/ip-whitelist", response_model=list[IPWhitelistOut])
def admin_ip_whitelist(_: AdminUser, db: Db) -> list[IPWhitelist]:
return list_whitelist(db)
@router.post("/admin/ip-whitelist", response_model=IPWhitelistOut)
def admin_add_ip_whitelist(body: IPWhitelistCreate, _: AdminUser, db: Db) -> IPWhitelist:
return add_whitelist_ip(db, body.ip_address, body.note)
@router.delete("/admin/ip-whitelist/{entry_id}", response_model=IPWhitelistOut)
def admin_remove_ip_whitelist(entry_id: int, _: AdminUser, db: Db) -> IPWhitelist:
return remove_whitelist_ip(db, entry_id)
@router.get("/admin/datacenter-applications", response_model=list[DatacenterView])
def admin_datacenter_applications(_: AdminUser, db: Db) -> list[DatacenterView]:
return [_datacenter_view(row) for row in list_applications(db)]
@router.post("/admin/datacenter-applications/{application_id}/approve", response_model=DatacenterView)
def admin_approve_datacenter(application_id: int, _: AdminUser, db: Db) -> DatacenterView:
return _datacenter_view(approve_application(db, application_id))
@router.post("/admin/datacenter-applications/{application_id}/reject", response_model=DatacenterView)
def admin_reject_datacenter(application_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> DatacenterView:
return _datacenter_view(reject_application(db, application_id, body.note))
@router.get("/admin/orders", response_model=list[OrderView])
def admin_orders(_: AdminUser, db: Db) -> list[OrderView]:
return [_order_view(row) for row in list_orders(db)]
@router.post("/admin/orders/{order_id}/approve", response_model=OrderView)
def admin_approve_order(order_id: int, _: AdminUser, db: Db) -> OrderView:
return _order_view(approve_order(db, order_id))
@router.post("/admin/orders/{order_id}/reject", response_model=OrderView)
def admin_reject_order(order_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> OrderView:
return _order_view(reject_order(db, order_id, body.note))
@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, request: Request, 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, client_ip=client_ip(request)
)
@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, request: Request, db: Db) -> HeartbeatOut:
hardware = None
if {"gpu_model", "vram_gb", "cpu_count", "memory_gb"} & body.model_fields_set:
hardware = HardwareReport(body.gpu_model, body.vram_gb, body.cpu_count, body.memory_gb)
node = record_heartbeat(
db,
body.sn,
body.token,
hardware=hardware,
client_ip=client_ip(request),
hardware_fingerprint=body.hardware_fingerprint,
)
days = node.recovery_days or 540
return HeartbeatOut(
sn=node.sn,
display_node_id=node.display_node_id,
status=node.status,
last_heartbeat_at=node.last_heartbeat_at,
server_time=node.last_heartbeat_at,
hardware_weight=node.hardware_weight,
gpu_generation=node.gpu_generation,
recovery_days=node.recovery_days,
recoverable_stake_amount=node.recoverable_stake_amount,
purchase_amount=node.purchase_amount,
estimated_daily_points=money(node.purchase_amount / Decimal(days)),
)
@router.get("/node/{sn}/tasks", response_model=list[TaskOut])
def pending_tasks(sn: str, db: Db, token: str | None = None) -> list[TaskOut]:
assert_node_token(get_node_by_sn(db, sn), token)
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:
assert_node_token(get_node_by_sn(db, body.sn), body.token)
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce, result=body.result))
@router.post("/admin/tasks/create", response_model=list[AdminTaskOut])
def admin_create_tasks(body: TaskCreate, _: AdminUser, db: Db) -> list[AdminTaskOut]:
tasks = create_admin_tasks(db, body.task_type, body.payload, body.target_node_sn)
return [_admin_task_out(task) for task in tasks]
@router.get("/admin/tasks/list", response_model=list[AdminTaskOut])
def admin_list_tasks(_: AdminUser, db: Db) -> list[AdminTaskOut]:
return [_admin_task_out(task) for task in list_admin_tasks(db)]
@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 = next(
(item for current, item in network_yields(db, yield_date) if current.id == node.id),
None,
)
if computed is None:
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=displayed_population(nodes),
online=displayed_population(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 _datacenter_out(row: DatacenterApplication) -> DatacenterOut:
return DatacenterOut.model_validate(row)
def _datacenter_view(row: DatacenterApplication) -> DatacenterView:
return DatacenterView(
**_datacenter_out(row).model_dump(),
wallet_address=row.user.wallet_address,
)
def _order_out(order: LicenseOrder) -> OrderOut:
return OrderOut(
id=order.id,
user_id=order.user_id,
amount=order.amount,
asset_symbol=order.asset_symbol,
status=order.status,
tx_id=order.tx_id,
created_at=order.created_at,
reviewed_at=order.reviewed_at,
sn=order.node.sn if order.node is not None else None,
display_node_id=display_node_id(order.node.node_index) if order.node is not None else None,
)
def _order_view(order: LicenseOrder) -> OrderView:
return OrderView(**_order_out(order).model_dump(), wallet_address=order.user.wallet_address)
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
return WithdrawalView(
**WithdrawalOut.model_validate(row).model_dump(),
wallet_address=row.user.wallet_address,
)
def _admin_task_out(task: ComputeTask) -> AdminTaskOut:
node = task.node
return AdminTaskOut(
task_id=task.id,
task_type=task.task_type,
target_node_sn=node.sn,
display_node_id=node.display_node_id,
status=task.status,
payload=task.payload or {},
result=task.result,
result_preview=result_preview(task.result),
dispatched_at=task.dispatched_at,
deadline_at=task.deadline_at,
responded_at=task.responded_at,
)
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,
)