"""对外 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, 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, 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 assert_node_token, record_heartbeat 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 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], orders=[_order_out(item) for item in list_orders(db, user.id)], license_price=money(settings.license_price), license_asset=settings.license_asset, withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals], ) @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, 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/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, 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, body.token) 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, 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)) @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 _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, ) 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 _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, )