"""对外 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.alpha_queue import assign_queued_alpha_task, maybe_generate_alpha_batch 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, ) maybe_generate_alpha_batch(db) assigned = assign_queued_alpha_task(db, node) 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)), assigned_task=_task_out(assigned) if assigned is not None else None, ) @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 if node is not None else "", display_node_id=node.display_node_id if node is not None else 0, 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, )