"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。""" from datetime import date, timedelta from decimal import Decimal from typing import Annotated from fastapi import APIRouter, Depends from sqlalchemy import func, select from sqlalchemy.orm import Session, selectinload from app.clock import utcnow from app.database import get_db from app.models import ComputeNode, ComputeTask, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus from app.money import money, to_decimal from app.schemas import ( BalanceOut, ContributionOut, HeartbeatIn, HeartbeatOut, NodeCreate, NodeOut, NodeView, SettleOut, SummaryOut, TaskOut, TaskReportIn, UserCreate, UserOut, WithdrawalConfirm, WithdrawalCreate, WithdrawalOut, WithdrawalReject, WithdrawalView, YieldOut, ) from app.services.common import get_node_by_sn, get_user_by_wallet from app.services.heartbeat import record_heartbeat 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.get("/summary", response_model=SummaryOut) def summary(db: Db) -> SummaryOut: users = db.scalar(select(func.count()).select_from(User)) or 0 nodes = db.scalar(select(func.count()).select_from(ComputeNode)) or 0 online = db.scalar( select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE) ) or 0 authorized = _sum(db.scalars(select(ComputeNode.purchase_amount)).all()) settled = _sum( db.scalars(select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)).all() ) pending = _sum( db.scalars( select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING) ).all() ) return SummaryOut( users=users, nodes=nodes, online=online, offline=nodes - online, authorized_amount=authorized, settled_points=settled, pending_withdrawals=pending, ) @router.get("/users", response_model=list[UserOut]) def list_users(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(db: Db) -> list[NodeView]: rows = db.scalars( select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc()) ).all() return [_node_view(row) for row in rows] @router.post("/nodes", response_model=NodeOut) def create_node(body: NodeCreate, db: Db) -> NodeOut: 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, db: Db) -> NodeOut: return get_node_by_sn(db, 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, db: Db) -> ContributionOut: node = get_node_by_sn(db, 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(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, db: Db) -> list[YieldOut]: return list_node_yields(db, sn) @router.get("/withdrawals", response_model=list[WithdrawalView]) def list_withdrawals(db: Db) -> list[WithdrawalView]: rows = db.scalars( select(WithdrawalLog).options(selectinload(WithdrawalLog.user)).order_by(WithdrawalLog.id.desc()) ).all() return [ WithdrawalView( **WithdrawalOut.model_validate(row).model_dump(), wallet_address=row.user.wallet_address, ) for row in rows ] @router.get("/users/{wallet_address}/balance", response_model=BalanceOut) def balance(wallet_address: str, db: Db) -> BalanceOut: user = get_user_by_wallet(db, wallet_address) return BalanceOut(wallet_address=user.wallet_address, available_points=available_balance(db, user.id)) @router.post("/withdrawals", response_model=WithdrawalOut) def create_withdrawal(body: WithdrawalCreate, db: Db) -> WithdrawalOut: 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, 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, db: Db) -> WithdrawalOut: return reject_withdrawal(db, withdrawal_id, body.note) def _node_view(node: ComputeNode) -> NodeView: return NodeView(**NodeOut.model_validate(node).model_dump(), wallet_address=node.user.wallet_address) def _sum(values: list[object]) -> Decimal: return money(sum((to_decimal(item) for item in values), Decimal("0"))) 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, )