78601aae5e
Co-authored-by: Cursor <cursoragent@cursor.com>
201 lines
6.8 KiB
Python
201 lines
6.8 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 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,
|
|
)
|