初始化 HyperNode-AI 控制面。

包含节点账本、每日清算,以及 Ubuntu 交互式部署脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 21:53:20 +08:00
commit 2bbdb00a4d
31 changed files with 2450 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
"""每日产出清算。
释放规则:
1. 授权金额在整个授权天数内线性释放。授权天数取 license 起止日期之差。
2. 在线率 = 当日授权窗口内的在线秒数 / 当日授权有效秒数。
尚未关闭的在线区间,若最后心跳已超过超时时间,只计算到最后一次心跳。
3. 任务响应率 = 校验通过且执行成功的任务数 / 当日内下发任务数。
4. 当天没有下发任务时,表现系数等于在线率,避免调度器故障误伤节点。
有任务时,表现系数 = 在线权重 × 在线率 + 任务权重 × 响应率。
5. 算力产出 = 授权金额 × 表现系数。
当日积分 = 算力产出 / 授权天数。
6. 单节点累计积分不超过授权金额。已存在的当日流水不会被覆盖。
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.clock import day_bounds, utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, NodeYield, TaskStatus, UptimeInterval, YieldStatus
from app.money import money, rate, ratio, to_decimal
from app.services.common import get_node_by_sn
from app.services.tasks import expire_overdue_tasks
@dataclass
class YieldComputation:
eligible: bool
yield_date: date
online_seconds: int = 0
eligible_seconds: int = 0
online_rate: Decimal = Decimal("0")
tasks_dispatched: int = 0
tasks_completed: int = 0
task_response_rate: Decimal = Decimal("0")
online_weight: Decimal = Decimal("0")
task_weight: Decimal = Decimal("0")
performance: Decimal = Decimal("0")
compute_output: Decimal = Decimal("0")
points_delta: Decimal = Decimal("0")
cycle_days: int = 0
@dataclass
class SettleSummary:
yield_date: date
created: int
skipped: int
total_points: Decimal
is_partial: bool
def compute_node_yield(
session: Session,
node: ComputeNode,
yield_date: date,
now: datetime | None = None,
) -> YieldComputation:
moment = now or utcnow()
day_start, day_end = day_bounds(yield_date)
window_start = max(day_start, node.licensed_at)
window_end = min(day_end, node.license_expires_at)
cycle_days = (node.license_expires_at.date() - node.licensed_at.date()).days
if window_end <= window_start or cycle_days <= 0:
return YieldComputation(eligible=False, yield_date=yield_date, cycle_days=max(cycle_days, 0))
eligible_seconds = int((window_end - window_start).total_seconds())
online_seconds = min(_online_seconds(session, node, window_start, window_end, moment), eligible_seconds)
online_rate = ratio(online_seconds, eligible_seconds)
tasks = session.scalars(
select(ComputeTask).where(
ComputeTask.node_id == node.id,
ComputeTask.dispatched_at >= window_start,
ComputeTask.dispatched_at < window_end,
)
).all()
tasks_dispatched = len(tasks)
tasks_completed = sum(1 for task in tasks if task.status == TaskStatus.COMPLETED)
response_rate = ratio(tasks_completed, tasks_dispatched) if tasks_dispatched else Decimal("0")
if tasks_dispatched == 0:
applied_online = Decimal("1")
applied_task = Decimal("0")
else:
applied_online = to_decimal(settings.online_weight)
applied_task = to_decimal(settings.task_weight)
performance = rate(applied_online * online_rate + applied_task * response_rate)
compute_output = money(to_decimal(node.purchase_amount) * performance)
points_delta = money(compute_output / Decimal(cycle_days))
points_delta = min(points_delta, _remaining_points(session, node))
return YieldComputation(
eligible=True,
yield_date=yield_date,
online_seconds=online_seconds,
eligible_seconds=eligible_seconds,
online_rate=online_rate,
tasks_dispatched=tasks_dispatched,
tasks_completed=tasks_completed,
task_response_rate=response_rate,
online_weight=money(applied_online),
task_weight=money(applied_task),
performance=performance,
compute_output=compute_output,
points_delta=points_delta,
cycle_days=cycle_days,
)
def settle_yield_date(session: Session, yield_date: date, now: datetime | None = None) -> SettleSummary:
"""清算指定 UTC 日。每个节点当天已有流水时跳过,保证脚本重复执行不会重复入账。"""
moment = now or utcnow()
if yield_date > moment.date():
raise AppError("不能清算未来日期")
expire_overdue_tasks(session, moment)
day_start, day_end = day_bounds(yield_date)
nodes = session.scalars(
select(ComputeNode)
.where(
ComputeNode.licensed_at < day_end,
ComputeNode.license_expires_at > day_start,
)
.order_by(ComputeNode.id)
).all()
created = 0
skipped = 0
total = Decimal("0")
partial = yield_date == moment.date()
for node in nodes:
exists = session.scalar(
select(NodeYield.id).where(NodeYield.node_id == node.id, NodeYield.yield_date == yield_date)
)
if exists is not None:
skipped += 1
continue
computed = compute_node_yield(session, node, yield_date, moment)
if not computed.eligible:
continue
try:
with session.begin_nested():
session.add(_to_row(node, computed, partial))
except IntegrityError:
skipped += 1
continue
created += 1
total += computed.points_delta
return SettleSummary(
yield_date=yield_date,
created=created,
skipped=skipped,
total_points=money(total),
is_partial=partial,
)
def list_node_yields(session: Session, sn: str) -> list[NodeYield]:
node = get_node_by_sn(session, sn.strip())
return list(
session.scalars(
select(NodeYield).where(NodeYield.node_id == node.id).order_by(NodeYield.yield_date.desc())
).all()
)
def _online_seconds(
session: Session,
node: ComputeNode,
window_start: datetime,
window_end: datetime,
now: datetime,
) -> int:
intervals = session.scalars(
select(UptimeInterval).where(
UptimeInterval.node_id == node.id,
UptimeInterval.started_at < window_end,
or_(UptimeInterval.ended_at.is_(None), UptimeInterval.ended_at > window_start),
)
).all()
total = 0
for interval in intervals:
start = max(interval.started_at, window_start)
end = min(_proven_end(interval, node, now), window_end)
if end > start:
total += int((end - start).total_seconds())
return total
def _proven_end(interval: UptimeInterval, node: ComputeNode, now: datetime) -> datetime:
"""开放区间只能证明到最后一次心跳;心跳仍在超时窗口内才延续到当前时间。"""
if interval.ended_at is not None:
return interval.ended_at
last_seen = node.last_heartbeat_at or interval.started_at
if last_seen + timedelta(minutes=settings.heartbeat_timeout_minutes) < now:
return last_seen
return now
def _remaining_points(session: Session, node: ComputeNode) -> Decimal:
paid_rows = session.scalars(select(NodeYield.points_delta).where(NodeYield.node_id == node.id)).all()
paid = sum((to_decimal(item) for item in paid_rows), Decimal("0"))
remaining = to_decimal(node.purchase_amount) - paid
if remaining <= 0:
return Decimal("0")
return money(remaining)
def _to_row(node: ComputeNode, computed: YieldComputation, partial: bool) -> NodeYield:
return NodeYield(
node_id=node.id,
user_id=node.user_id,
yield_date=computed.yield_date,
compute_output=computed.compute_output,
points_delta=computed.points_delta,
online_seconds=computed.online_seconds,
eligible_seconds=computed.eligible_seconds,
online_rate=computed.online_rate,
tasks_dispatched=computed.tasks_dispatched,
tasks_completed=computed.tasks_completed,
task_response_rate=computed.task_response_rate,
online_weight=computed.online_weight,
task_weight=computed.task_weight,
performance=computed.performance,
cycle_days=computed.cycle_days,
status=YieldStatus.SETTLED,
is_partial=partial,
created_at=utcnow(),
)