初始化 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
+2
View File
@@ -0,0 +1,2 @@
* text=auto
*.sh text eol=lf
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
venv/
.env
*.db
.pytest_cache/
+1
View File
@@ -0,0 +1 @@
"""DePIN 算力节点与任务调度控制面。"""
+6
View File
@@ -0,0 +1,6 @@
"""python -m app 启动 API。每日清算请使用 python -m app.settle。"""
import uvicorn
if __name__ == "__main__":
uvicorn.run("app.main:app", host="0.0.0.0", port=8000)
+132
View File
@@ -0,0 +1,132 @@
"""对外 HTTP 接口。业务规则在 services 中,这里只做参数转发。"""
from datetime import date, timedelta
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.database import get_db
from app.models import ComputeTask
from app.schemas import (
BalanceOut,
ContributionOut,
HeartbeatIn,
HeartbeatOut,
NodeCreate,
NodeOut,
SettleOut,
TaskOut,
TaskReportIn,
UserCreate,
UserOut,
WithdrawalConfirm,
WithdrawalCreate,
WithdrawalOut,
WithdrawalReject,
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.post("/users", response_model=UserOut)
def create_user(body: UserCreate, db: Db) -> UserOut:
return register_user(db, body.wallet_address)
@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("/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 _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,
)
+27
View File
@@ -0,0 +1,27 @@
"""UTC 时间工具。账本、心跳和授权窗口全部使用 UTC。"""
from __future__ import annotations
import calendar
from datetime import date, datetime, time, timedelta, timezone
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def day_bounds(value: date) -> tuple[datetime, datetime]:
"""返回某个 UTC 日期的左闭右开区间。"""
start = datetime.combine(value, time.min, tzinfo=timezone.utc)
return start, start + timedelta(days=1)
def add_months(value: datetime, months: int) -> datetime:
"""按日历月平移,月末日期自动收敛到目标月最后一天。"""
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
month_index = value.month - 1 + months
year = value.year + month_index // 12
month = month_index % 12 + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return value.replace(year=year, month=month, day=day)
+31
View File
@@ -0,0 +1,31 @@
"""运行参数。可用环境变量或项目根目录的 .env 覆盖。"""
from decimal import Decimal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
database_url: str = "sqlite:///./depin.db"
# 超过该时长没有心跳,节点会被置为 offline。
heartbeat_timeout_minutes: int = 15
# 在线巡检周期。
offline_check_minutes: int = 5
# 随机任务下发周期、单轮最多下发数量,以及节点必须回传的时限。
task_dispatch_minutes: int = 5
dispatch_batch_size: int = 20
task_deadline_minutes: int = 10
# 当日既有在线样本又有任务样本时的权重。两者默认相加为 1。
online_weight: Decimal = Decimal("0.7")
task_weight: Decimal = Decimal("0.3")
# 每日清算时刻。默认北京时间凌晨 00:05,入账的是上一个已结束的 UTC 账本日。
yield_timezone: str = "Asia/Shanghai"
yield_hour: int = 0
yield_minute: int = 5
license_months: int = 12
disable_scheduler: bool = False
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
+71
View File
@@ -0,0 +1,71 @@
"""数据库引擎与会话。SQLite 仅用于本地启动,生产环境把 DATABASE_URL 指到 PostgreSQL。"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime, timezone
from sqlalchemy import DateTime, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.types import TypeDecorator
from app.config import settings
class UtcDateTime(TypeDecorator):
"""统一读写 UTC,避免 SQLite 把时区信息丢掉后比较失败。"""
impl = DateTime
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def process_result_value(self, value, dialect):
if value is None:
return None
if isinstance(value, str):
text = value.replace("Z", "+00:00")
value = datetime.fromisoformat(text)
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
class Base(DeclarativeBase):
pass
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, connect_args=connect_args, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)
@contextmanager
def session_scope() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def get_db() -> Iterator[Session]:
session = SessionLocal()
try:
yield session
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
+8
View File
@@ -0,0 +1,8 @@
"""业务异常。路由层把它转换成对应的 HTTP 状态码。"""
class AppError(Exception):
def __init__(self, message: str, status_code: int = 400) -> None:
super().__init__(message)
self.message = message
self.status_code = status_code
+1
View File
@@ -0,0 +1 @@
"""后台定时任务。"""
+41
View File
@@ -0,0 +1,41 @@
"""每天凌晨的积分清算任务。"""
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta, timezone
from app.clock import utcnow
from app.database import session_scope
from app.services.heartbeat import mark_stale_nodes
from app.services.yield_engine import SettleSummary, settle_yield_date
logger = logging.getLogger(__name__)
def run_daily_yield_cron(now: datetime | None = None, yield_date: date | None = None) -> SettleSummary:
"""计算所有仍在授权期内的节点,并把当日积分写入 node_yields。
调度器在北京时间 00:05 调用本函数。账本按 UTC 日切分,因此入账日期是
调用时刻之前最近一个已经结束的 UTC 日,而不是刚开始的这一天。
同一节点同一天已有流水时跳过,重复触发不会重复入账。
"""
moment = now or utcnow()
if moment.tzinfo is None:
raise ValueError("清算时间必须带时区")
target = yield_date or (moment.astimezone(timezone.utc).date() - timedelta(days=1))
with session_scope() as session:
stale = mark_stale_nodes(session, moment)
summary = settle_yield_date(session, target, moment)
logger.info(
"daily yield settled date=%s created=%s skipped=%s points=%s partial=%s stale_nodes=%s",
summary.yield_date,
summary.created,
summary.skipped,
format(summary.total_points, "f"),
summary.is_partial,
len(stale),
)
return summary
+41
View File
@@ -0,0 +1,41 @@
"""应用入口。"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
import app.models # noqa: F401 注册全部表
from app.api import router
from app.database import Base, engine
from app.errors import AppError
from app.scheduler import start_scheduler, stop_scheduler
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@asynccontextmanager
async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine)
start_scheduler()
try:
yield
finally:
stop_scheduler()
app = FastAPI(title="DePIN 算力节点与任务调度", version="1.0.0", lifespan=lifespan)
app.include_router(router)
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+251
View File
@@ -0,0 +1,251 @@
"""DePIN 控制面数据模型。
核心表:
- User:用户与钱包地址
- ComputeNode:算力节点、授权金额、12 个月授权周期、在线状态
- NodeYield:每日算力产出与积分流水
- WithdrawalLog:数字资产提现申请、链上哈希与状态
支撑表(业务计算所依赖,不单独对外当作资产账本):
- UptimeInterval:一次连续在线区间,用来计算有效在线时长
- ComputeTask:下发给节点的模拟计算任务及回传校验结果
- SchedulerLock:多实例部署时,定时任务靠数据库互斥,避免重复下发和重复清算
"""
from __future__ import annotations
import enum
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
CheckConstraint,
Enum,
ForeignKey,
Index,
Integer,
Numeric,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.types import JSON, TypeDecorator
from app.clock import utcnow
from app.database import Base, UtcDateTime
from app.money import money
class PreciseDecimal(TypeDecorator):
"""金额与比率。SQLite 会把 Numeric 读成 float,这里统一还原成 8 位小数。"""
impl = Numeric(24, 8)
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
return money(value)
def process_result_value(self, value, dialect):
if value is None:
return None
return money(value)
MONEY = PreciseDecimal()
RATE = PreciseDecimal()
def _enum(enum_cls: type[enum.Enum], length: int = 16) -> Enum:
"""把枚举的 value(online/offline)写入数据库,而不是成员名。"""
return Enum(
enum_cls,
native_enum=False,
length=length,
values_callable=lambda obj: [item.value for item in obj],
)
class NodeStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
class YieldStatus(str, enum.Enum):
SETTLED = "settled"
VOID = "void"
class WithdrawalStatus(str, enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
REJECTED = "rejected"
FAILED = "failed"
class TaskStatus(str, enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
EXPIRED = "expired"
class PushStatus(str, enum.Enum):
QUEUED = "queued"
PUSHED = "pushed"
FAILED = "failed"
class User(Base):
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
wallet_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
nodes: Mapped[list[ComputeNode]] = relationship(back_populates="user")
yields: Mapped[list[NodeYield]] = relationship(back_populates="user")
withdrawals: Mapped[list[WithdrawalLog]] = relationship(back_populates="user")
class ComputeNode(Base):
"""算力节点。购买金额在授权周期内按天线性释放,实际到账再乘当日表现系数。"""
__tablename__ = "compute_nodes"
__table_args__ = (
CheckConstraint("purchase_amount > 0", name="ck_node_purchase_positive"),
CheckConstraint("license_months > 0", name="ck_node_license_months"),
Index("ix_node_status_heartbeat", "status", "last_heartbeat_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
sn: Mapped[str] = mapped_column(String(64), unique=True, index=True, comment="节点唯一序列号")
purchase_amount: Mapped[Decimal] = mapped_column(MONEY, comment="授权购买金额")
license_months: Mapped[int] = mapped_column(Integer, default=12)
licensed_at: Mapped[datetime] = mapped_column(UtcDateTime, comment="授权开始时间")
license_expires_at: Mapped[datetime] = mapped_column(UtcDateTime, comment="授权结束时间")
status: Mapped[NodeStatus] = mapped_column(_enum(NodeStatus), default=NodeStatus.OFFLINE, index=True)
last_heartbeat_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
callback_url: Mapped[str | None] = mapped_column(String(512), comment="可选的任务推送地址")
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow, onupdate=utcnow)
user: Mapped[User] = relationship(back_populates="nodes")
yields: Mapped[list[NodeYield]] = relationship(back_populates="node")
tasks: Mapped[list[ComputeTask]] = relationship(back_populates="node")
uptime_intervals: Mapped[list[UptimeInterval]] = relationship(back_populates="node")
class UptimeInterval(Base):
"""一段连续在线时间。心跳首次上线时打开,超时或授权到期时关闭。"""
__tablename__ = "uptime_intervals"
__table_args__ = (Index("ix_uptime_node_window", "node_id", "started_at", "ended_at"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True)
started_at: Mapped[datetime] = mapped_column(UtcDateTime)
ended_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
close_reason: Mapped[str | None] = mapped_column(String(32))
node: Mapped[ComputeNode] = relationship(back_populates="uptime_intervals")
class ComputeTask(Base):
"""下发给在线节点的模拟计算任务。客户端须回传 task_id 与一次性 nonce。"""
__tablename__ = "compute_tasks"
__table_args__ = (
Index("ix_task_node_status", "node_id", "status"),
Index("ix_task_dispatched_at", "dispatched_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, comment="task_id")
node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True)
task_type: Mapped[str] = mapped_column(String(32), default="simulated_compute")
payload: Mapped[dict] = mapped_column(JSON)
status: Mapped[TaskStatus] = mapped_column(_enum(TaskStatus), default=TaskStatus.PENDING, index=True)
push_status: Mapped[PushStatus] = mapped_column(_enum(PushStatus), default=PushStatus.QUEUED)
push_error: Mapped[str | None] = mapped_column(String(255))
dispatched_at: Mapped[datetime] = mapped_column(UtcDateTime)
deadline_at: Mapped[datetime] = mapped_column(UtcDateTime)
responded_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
success: Mapped[bool | None] = mapped_column()
node: Mapped[ComputeNode] = relationship(back_populates="tasks")
class NodeYield(Base):
"""每日产出流水。同一节点同一天只有一条结算记录。"""
__tablename__ = "node_yields"
__table_args__ = (
UniqueConstraint("node_id", "yield_date", name="uq_node_yield_date"),
Index("ix_node_yield_user_date", "user_id", "yield_date"),
CheckConstraint("points_delta >= 0", name="ck_yield_points_non_negative"),
CheckConstraint("compute_output >= 0", name="ck_yield_output_non_negative"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
node_id: Mapped[int] = mapped_column(ForeignKey("compute_nodes.id"), index=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
yield_date: Mapped[date] = mapped_column(index=True, comment="清算日,UTC")
compute_output: Mapped[Decimal] = mapped_column(MONEY, comment="当日有效算力产出,等于授权金额乘表现系数")
points_delta: Mapped[Decimal] = mapped_column(MONEY, comment="当日入账积分")
online_seconds: Mapped[int] = mapped_column(Integer, default=0)
eligible_seconds: Mapped[int] = mapped_column(Integer, default=0)
online_rate: Mapped[Decimal] = mapped_column(RATE, default=0)
tasks_dispatched: Mapped[int] = mapped_column(Integer, default=0)
tasks_completed: Mapped[int] = mapped_column(Integer, default=0)
task_response_rate: Mapped[Decimal] = mapped_column(RATE, default=0)
online_weight: Mapped[Decimal] = mapped_column(RATE, default=0)
task_weight: Mapped[Decimal] = mapped_column(RATE, default=0)
performance: Mapped[Decimal] = mapped_column(RATE, default=0, comment="当日表现系数,0 到 1")
cycle_days: Mapped[int] = mapped_column(Integer)
status: Mapped[YieldStatus] = mapped_column(_enum(YieldStatus), default=YieldStatus.SETTLED)
is_partial: Mapped[bool] = mapped_column(default=False, comment="结算时该 UTC 日尚未结束")
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
node: Mapped[ComputeNode] = relationship(back_populates="yields")
user: Mapped[User] = relationship(back_populates="yields")
class WithdrawalLog(Base):
"""提现申请。待处理和已完成的金额都会占用可用积分,拒绝后释放。"""
__tablename__ = "withdrawal_logs"
__table_args__ = (
Index("ix_withdrawal_user_status", "user_id", "status"),
CheckConstraint("amount > 0", name="ck_withdrawal_amount_positive"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
amount: Mapped[Decimal] = mapped_column(MONEY)
asset_symbol: Mapped[str] = mapped_column(String(32), default="POINT")
tx_hash: Mapped[str | None] = mapped_column(String(128), unique=True)
status: Mapped[WithdrawalStatus] = mapped_column(
_enum(WithdrawalStatus),
default=WithdrawalStatus.PENDING,
index=True,
)
note: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow, onupdate=utcnow)
user: Mapped[User] = relationship(back_populates="withdrawals")
class SchedulerLock(Base):
"""定时任务锁。锁到期后其他实例才能接手,进程崩溃不会永久占住任务。"""
__tablename__ = "scheduler_locks"
name: Mapped[str] = mapped_column(String(64), primary_key=True)
locked_until: Mapped[datetime] = mapped_column(UtcDateTime)
owner: Mapped[str] = mapped_column(String(128))
+32
View File
@@ -0,0 +1,32 @@
"""金额与比率的定点运算。积分只向下取整,避免四舍五入把释放总额做超。"""
from __future__ import annotations
from decimal import Decimal, ROUND_DOWN
QUANT = Decimal("0.00000001")
def to_decimal(value: object) -> Decimal:
if isinstance(value, Decimal):
return value
return Decimal(str(value))
def money(value: object) -> Decimal:
return to_decimal(value).quantize(QUANT, rounding=ROUND_DOWN)
def rate(value: object) -> Decimal:
number = to_decimal(value)
if number < 0:
number = Decimal("0")
elif number > 1:
number = Decimal("1")
return number.quantize(QUANT, rounding=ROUND_DOWN)
def ratio(numerator: int, denominator: int) -> Decimal:
if denominator <= 0:
return Decimal("0")
return rate(Decimal(numerator) / Decimal(denominator))
+107
View File
@@ -0,0 +1,107 @@
"""进程内定时任务:离线巡检、随机任务下发、每日清算。"""
from __future__ import annotations
import logging
from datetime import timedelta
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
from zoneinfo import ZoneInfo
from app.config import settings
from app.database import session_scope
from app.jobs.daily_yield import run_daily_yield_cron
from app.services.heartbeat import mark_stale_nodes
from app.services.locks import acquire_lock
from app.services.tasks import dispatch_random_tasks
logger = logging.getLogger(__name__)
UTC = ZoneInfo("UTC")
_scheduler: BackgroundScheduler | None = None
def start_scheduler() -> None:
global _scheduler
if settings.disable_scheduler:
logger.info("scheduler disabled")
return
if _scheduler is not None and _scheduler.running:
return
scheduler = BackgroundScheduler(timezone=UTC, job_defaults={"coalesce": True, "max_instances": 1})
scheduler.add_job(
_guard(_offline_job),
IntervalTrigger(minutes=settings.offline_check_minutes, timezone=UTC),
id="offline_check",
replace_existing=True,
misfire_grace_time=120,
)
scheduler.add_job(
_guard(_dispatch_job),
IntervalTrigger(minutes=settings.task_dispatch_minutes, timezone=UTC),
id="task_dispatch",
replace_existing=True,
misfire_grace_time=120,
)
yield_zone = ZoneInfo(settings.yield_timezone)
scheduler.add_job(
_guard(_yield_job),
CronTrigger(hour=settings.yield_hour, minute=settings.yield_minute, timezone=yield_zone),
id="daily_yield",
replace_existing=True,
misfire_grace_time=3600,
)
scheduler.start()
_scheduler = scheduler
logger.info(
"scheduler started; daily yield at %02d:%02d %s",
settings.yield_hour,
settings.yield_minute,
settings.yield_timezone,
)
def stop_scheduler() -> None:
global _scheduler
if _scheduler is not None and _scheduler.running:
_scheduler.shutdown(wait=False)
_scheduler = None
def _guard(func):
def wrapper() -> None:
try:
func()
except Exception:
logger.exception("scheduled job failed: %s", func.__name__)
return wrapper
def _offline_job() -> None:
ttl = timedelta(seconds=max(30, settings.offline_check_minutes * 60 - 30))
if not acquire_lock("offline_check", ttl):
return
with session_scope() as session:
changed = mark_stale_nodes(session)
if changed:
logger.info("marked offline: %s", ",".join(changed))
def _dispatch_job() -> None:
ttl = timedelta(seconds=max(30, settings.task_dispatch_minutes * 60 - 30))
if not acquire_lock("task_dispatch", ttl):
return
with session_scope() as session:
tasks = dispatch_random_tasks(session)
if tasks:
logger.info("dispatched %s tasks", len(tasks))
def _yield_job() -> None:
if not acquire_lock("daily_yield", timedelta(hours=2)):
logger.info("daily yield skipped, lock held")
return
run_daily_yield_cron()
+156
View File
@@ -0,0 +1,156 @@
"""接口请求与响应。金额在 JSON 里用字符串,避免浮点误差。"""
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
from app.models import NodeStatus, TaskStatus, WithdrawalStatus, YieldStatus
SN = Annotated[str, Field(min_length=4, max_length=64, pattern=r"^[A-Za-z0-9_-]+$")]
WALLET = Annotated[str, Field(min_length=4, max_length=128, pattern=r"^[A-Za-z0-9:_-]+$")]
MoneyText = Annotated[Decimal, PlainSerializer(lambda value: format(value, "f"), return_type=str)]
class APIModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
class UserCreate(APIModel):
wallet_address: WALLET
class UserOut(APIModel):
id: int
wallet_address: str
created_at: datetime
class NodeCreate(APIModel):
wallet_address: WALLET
sn: SN
purchase_amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
callback_url: str | None = None
class NodeOut(APIModel):
id: int
user_id: int
sn: str
purchase_amount: MoneyText
license_months: int
licensed_at: datetime
license_expires_at: datetime
status: NodeStatus
last_heartbeat_at: datetime | None
callback_url: str | None
class HeartbeatIn(APIModel):
sn: SN
class HeartbeatOut(APIModel):
sn: str
status: NodeStatus
last_heartbeat_at: datetime
server_time: datetime
class TaskOut(APIModel):
task_id: str
task_type: str
payload: dict
status: TaskStatus
dispatched_at: datetime
deadline_at: datetime
class TaskReportIn(APIModel):
sn: SN
task_id: str = Field(min_length=36, max_length=36)
success: bool
nonce: str = Field(min_length=8, max_length=128)
class ContributionOut(APIModel):
sn: str
yield_date: date
eligible: bool
online_seconds: int
eligible_seconds: int
online_rate: MoneyText
tasks_dispatched: int
tasks_completed: int
task_response_rate: MoneyText
online_weight: MoneyText
task_weight: MoneyText
performance: MoneyText
compute_output: MoneyText
points_delta: MoneyText
cycle_days: int
class YieldOut(APIModel):
id: int
node_id: int
user_id: int
yield_date: date
compute_output: MoneyText
points_delta: MoneyText
online_seconds: int
eligible_seconds: int
online_rate: MoneyText
tasks_dispatched: int
tasks_completed: int
task_response_rate: MoneyText
online_weight: MoneyText
task_weight: MoneyText
performance: MoneyText
cycle_days: int
status: YieldStatus
is_partial: bool
created_at: datetime
class SettleOut(APIModel):
yield_date: date
created: int
skipped: int
total_points: MoneyText
is_partial: bool
class WithdrawalCreate(APIModel):
wallet_address: WALLET
amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
class WithdrawalConfirm(APIModel):
tx_hash: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
class WithdrawalReject(APIModel):
note: str | None = Field(default=None, max_length=500)
class WithdrawalOut(APIModel):
id: int
user_id: int
amount: MoneyText
asset_symbol: str
tx_hash: str | None
status: WithdrawalStatus
note: str | None
available_after: MoneyText | None = None
created_at: datetime
updated_at: datetime
class BalanceOut(APIModel):
wallet_address: str
available_points: MoneyText
+1
View File
@@ -0,0 +1 @@
"""业务服务。"""
+21
View File
@@ -0,0 +1,21 @@
"""按序列号或钱包地址读取主体,供各业务模块共用。"""
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.errors import AppError
from app.models import ComputeNode, User
def get_user_by_wallet(session: Session, wallet_address: str) -> User:
user = session.scalar(select(User).where(User.wallet_address == wallet_address))
if user is None:
raise AppError("用户不存在", 404)
return user
def get_node_by_sn(session: Session, sn: str) -> ComputeNode:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
if node is None:
raise AppError(f"节点不存在: {sn}", 404)
return node
+78
View File
@@ -0,0 +1,78 @@
"""心跳登记与离线巡检。"""
from __future__ import annotations
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, NodeStatus, UptimeInterval
from app.services.common import get_node_by_sn
def record_heartbeat(session: Session, sn: str, now: datetime | None = None) -> ComputeNode:
"""刷新最后活跃时间。离线节点收到心跳后重新打开一段在线区间。"""
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
if node.license_expires_at <= moment:
raise AppError("节点授权已到期", 403)
node.last_heartbeat_at = moment
node.updated_at = moment
if node.status != NodeStatus.ONLINE:
node.status = NodeStatus.ONLINE
session.add(UptimeInterval(node_id=node.id, started_at=moment))
else:
_ensure_open_interval(session, node.id, moment)
session.flush()
return node
def mark_stale_nodes(session: Session, now: datetime | None = None) -> list[str]:
"""把超过心跳时限或授权已到期的在线节点置为 offline,并关闭在线区间。"""
moment = now or utcnow()
cutoff = moment - timedelta(minutes=settings.heartbeat_timeout_minutes)
nodes = session.scalars(select(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE)).all()
changed: list[str] = []
for node in nodes:
reasons: list[tuple[datetime, str]] = []
if node.last_heartbeat_at is None or node.last_heartbeat_at < cutoff:
reasons.append((node.last_heartbeat_at or moment, "heartbeat_timeout"))
if node.license_expires_at <= moment:
reasons.append((node.license_expires_at, "license_expired"))
if not reasons:
continue
ended_at, reason = min(reasons, key=lambda item: item[0])
_close_open_intervals(session, node.id, ended_at, reason)
node.status = NodeStatus.OFFLINE
node.updated_at = moment
changed.append(node.sn)
session.flush()
return changed
def _ensure_open_interval(session: Session, node_id: int, started_at: datetime) -> None:
opened = session.scalar(
select(UptimeInterval).where(
UptimeInterval.node_id == node_id,
UptimeInterval.ended_at.is_(None),
)
)
if opened is None:
session.add(UptimeInterval(node_id=node_id, started_at=started_at))
def _close_open_intervals(session: Session, node_id: int, ended_at: datetime, reason: str) -> None:
rows = session.scalars(
select(UptimeInterval).where(
UptimeInterval.node_id == node_id,
UptimeInterval.ended_at.is_(None),
)
).all()
for row in rows:
row.ended_at = ended_at if ended_at >= row.started_at else row.started_at
row.close_reason = reason
+41
View File
@@ -0,0 +1,41 @@
"""多实例定时任务互斥。锁有过期时间,持有进程异常退出后其他实例仍能继续。"""
from __future__ import annotations
import os
import socket
from datetime import timedelta
from sqlalchemy import update
from sqlalchemy.exc import IntegrityError
from app.clock import utcnow
from app.database import session_scope
from app.models import SchedulerLock
OWNER = f"{socket.gethostname()}:{os.getpid()}"
def acquire_lock(name: str, ttl: timedelta) -> bool:
now = utcnow()
until = now + ttl
try:
with session_scope() as session:
lock = session.get(SchedulerLock, name)
if lock is None:
session.add(SchedulerLock(name=name, locked_until=until, owner=OWNER))
try:
session.flush()
except IntegrityError:
session.rollback()
return False
return True
updated = session.execute(
update(SchedulerLock)
.where(SchedulerLock.name == name)
.where(SchedulerLock.locked_until <= now)
.values(locked_until=until, owner=OWNER)
)
return updated.rowcount == 1
except IntegrityError:
return False
+74
View File
@@ -0,0 +1,74 @@
"""用户与节点登记。"""
from __future__ import annotations
from decimal import Decimal
from urllib.parse import urlparse
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.clock import add_months, utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, NodeStatus, User
from app.money import money
from app.services.common import get_user_by_wallet
def register_user(session: Session, wallet_address: str) -> User:
wallet = wallet_address.strip()
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
raise AppError("钱包地址已注册", 409)
user = User(wallet_address=wallet, created_at=utcnow())
session.add(user)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise AppError("钱包地址已注册", 409) from exc
return user
def register_node(
session: Session,
wallet_address: str,
sn: str,
purchase_amount: Decimal,
callback_url: str | None = None,
) -> ComputeNode:
user = get_user_by_wallet(session, wallet_address.strip())
serial = sn.strip()
if session.scalar(select(ComputeNode).where(ComputeNode.sn == serial)) is not None:
raise AppError("节点序列号已存在", 409)
callback = _normalize_callback(callback_url)
started = utcnow()
node = ComputeNode(
user_id=user.id,
sn=serial,
purchase_amount=money(purchase_amount),
license_months=settings.license_months,
licensed_at=started,
license_expires_at=add_months(started, settings.license_months),
status=NodeStatus.OFFLINE,
callback_url=callback,
created_at=started,
updated_at=started,
)
session.add(node)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise AppError("节点序列号已存在", 409) from exc
return node
def _normalize_callback(callback_url: str | None) -> str | None:
if callback_url is None or callback_url.strip() == "":
return None
parsed = urlparse(callback_url.strip())
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise AppError("callback_url 必须是 http 或 https 地址")
return callback_url.strip()
+183
View File
@@ -0,0 +1,183 @@
"""随机任务下发与回传校验。
节点不暴露回调地址时,任务留在待拉取队列,客户端用
GET /api/v1/node/{sn}/tasks 领取。配置了 callback_url 时,调度器会额外 POST 一次。
回传必须带上任务里的 nonce,避免未收到任务的客户端直接上报成功。
"""
from __future__ import annotations
import hmac
import json
import logging
import random
import secrets
import uuid
from datetime import datetime, timedelta
from urllib.error import URLError
from urllib.request import Request, urlopen
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, NodeStatus, PushStatus, TaskStatus
from app.services.common import get_node_by_sn
logger = logging.getLogger(__name__)
def dispatch_random_tasks(session: Session, now: datetime | None = None) -> list[ComputeTask]:
"""向当前在线、授权仍有效、且没有未完成任务的节点随机下发一轮任务。"""
moment = now or utcnow()
expire_overdue_tasks(session, moment)
pending_node_ids = set(
session.scalars(select(ComputeTask.node_id).where(ComputeTask.status == TaskStatus.PENDING)).all()
)
candidates = [
node
for node in session.scalars(
select(ComputeNode).where(
ComputeNode.status == NodeStatus.ONLINE,
ComputeNode.license_expires_at > moment,
)
).all()
if node.id not in pending_node_ids
]
if not candidates:
return []
batch = min(len(candidates), settings.dispatch_batch_size)
chosen = random.sample(candidates, batch)
deadline = moment + timedelta(minutes=settings.task_deadline_minutes)
tasks: list[ComputeTask] = []
for node in chosen:
task = ComputeTask(
id=str(uuid.uuid4()),
node_id=node.id,
task_type="simulated_compute",
payload={
"op": "simulated_compute",
"nonce": secrets.token_hex(16),
"seed": secrets.token_hex(8),
},
status=TaskStatus.PENDING,
push_status=PushStatus.QUEUED,
dispatched_at=moment,
deadline_at=deadline,
)
session.add(task)
tasks.append(task)
session.flush()
for node, task in zip(chosen, tasks):
_push_task(node, task)
session.flush()
return tasks
def list_pending_tasks(session: Session, sn: str, now: datetime | None = None) -> list[ComputeTask]:
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
tasks = session.scalars(
select(ComputeTask)
.where(ComputeTask.node_id == node.id, ComputeTask.status == TaskStatus.PENDING)
.order_by(ComputeTask.dispatched_at)
).all()
visible: list[ComputeTask] = []
for task in tasks:
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
continue
visible.append(task)
session.flush()
return visible
def report_task(
session: Session,
sn: str,
task_id: str,
success: bool,
nonce: str,
now: datetime | None = None,
) -> ComputeTask:
"""校验任务归属、时限和 nonce。只有校验通过且 success 为真才记为有效响应。"""
moment = now or utcnow()
node = get_node_by_sn(session, sn.strip())
task = session.get(ComputeTask, task_id)
if task is None:
raise AppError("任务不存在", 404)
if task.node_id != node.id:
raise AppError("任务不属于该节点", 403)
if task.status != TaskStatus.PENDING:
raise AppError("任务已结束,不能重复回传", 409)
if task.deadline_at < moment:
task.status = TaskStatus.EXPIRED
session.flush()
raise AppError("任务已超过回传时限", 409)
expected = str((task.payload or {}).get("nonce", ""))
if not _nonce_matches(nonce, expected):
raise AppError("任务响应校验失败")
task.responded_at = moment
task.success = success
task.status = TaskStatus.COMPLETED if success else TaskStatus.FAILED
session.flush()
return task
def expire_overdue_tasks(session: Session, now: datetime | None = None) -> int:
moment = now or utcnow()
tasks = session.scalars(
select(ComputeTask).where(
ComputeTask.status == TaskStatus.PENDING,
ComputeTask.deadline_at < moment,
)
).all()
for task in tasks:
task.status = TaskStatus.EXPIRED
if tasks:
session.flush()
return len(tasks)
def _nonce_matches(provided: str, expected: str) -> bool:
if not expected or len(provided) != len(expected):
return False
return hmac.compare_digest(provided, expected)
def _push_task(node: ComputeNode, task: ComputeTask) -> None:
if not node.callback_url:
task.push_status = PushStatus.QUEUED
return
body = json.dumps(
{
"task_id": task.id,
"sn": node.sn,
"task_type": task.task_type,
"payload": task.payload,
"deadline_at": task.deadline_at.isoformat(),
}
).encode()
request = Request(
node.callback_url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(request, timeout=2) as response:
if 200 <= response.status < 300:
task.push_status = PushStatus.PUSHED
task.push_error = None
return
task.push_status = PushStatus.FAILED
task.push_error = f"HTTP {response.status}"
except (URLError, TimeoutError, OSError) as exc:
task.push_status = PushStatus.FAILED
task.push_error = str(exc)[:255]
logger.warning("task push failed sn=%s task=%s error=%s", node.sn, task.id, exc)
+99
View File
@@ -0,0 +1,99 @@
"""提现申请。可用余额 = 已入账积分 - 待处理提现 - 已完成提现。"""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.errors import AppError
from app.models import NodeYield, WithdrawalLog, WithdrawalStatus, YieldStatus
from app.money import money, to_decimal
from app.services.common import get_user_by_wallet
def available_balance(session: Session, user_id: int) -> Decimal:
credited_rows = session.scalars(
select(NodeYield.points_delta).where(
NodeYield.user_id == user_id,
NodeYield.status == YieldStatus.SETTLED,
)
).all()
reserved_rows = session.scalars(
select(WithdrawalLog.amount).where(
WithdrawalLog.user_id == user_id,
WithdrawalLog.status.in_([WithdrawalStatus.PENDING, WithdrawalStatus.COMPLETED]),
)
).all()
credited = sum((to_decimal(item) for item in credited_rows), Decimal("0"))
reserved = sum((to_decimal(item) for item in reserved_rows), Decimal("0"))
balance = credited - reserved
if balance <= 0:
return Decimal("0")
return money(balance)
def request_withdrawal(
session: Session,
wallet_address: str,
amount: Decimal,
asset_symbol: str = "POINT",
) -> tuple[WithdrawalLog, Decimal]:
user = get_user_by_wallet(session, wallet_address.strip())
value = money(amount)
if value <= 0:
raise AppError("提现金额必须大于 0")
balance = available_balance(session, user.id)
if value > balance:
raise AppError("可用积分不足")
row = WithdrawalLog(
user_id=user.id,
amount=value,
asset_symbol=asset_symbol.strip().upper() or "POINT",
status=WithdrawalStatus.PENDING,
created_at=utcnow(),
updated_at=utcnow(),
)
session.add(row)
session.flush()
return row, money(balance - value)
def confirm_withdrawal(session: Session, withdrawal_id: int, tx_hash: str) -> WithdrawalLog:
row = _get_withdrawal(session, withdrawal_id)
if row.status != WithdrawalStatus.PENDING:
raise AppError("仅待处理的提现可以确认", 409)
digest = tx_hash.strip()
occupied = session.scalar(select(WithdrawalLog.id).where(WithdrawalLog.tx_hash == digest))
if occupied is not None:
raise AppError("交易哈希已存在", 409)
row.tx_hash = digest
row.status = WithdrawalStatus.COMPLETED
row.updated_at = utcnow()
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise AppError("交易哈希已存在", 409) from exc
return row
def reject_withdrawal(session: Session, withdrawal_id: int, note: str | None = None) -> WithdrawalLog:
row = _get_withdrawal(session, withdrawal_id)
if row.status != WithdrawalStatus.PENDING:
raise AppError("仅待处理的提现可以拒绝", 409)
row.status = WithdrawalStatus.REJECTED
row.note = note
row.updated_at = utcnow()
session.flush()
return row
def _get_withdrawal(session: Session, withdrawal_id: int) -> WithdrawalLog:
row = session.get(WithdrawalLog, withdrawal_id)
if row is None:
raise AppError("提现记录不存在", 404)
return row
+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(),
)
+35
View File
@@ -0,0 +1,35 @@
"""独立的每日清算脚本。服务进程里的定时任务也会调用同一套结算函数。
示例:
python -m app.settle
python -m app.settle --date 2026-10-09
"""
from __future__ import annotations
import argparse
import logging
from datetime import date
import app.models # noqa: F401
from app.database import Base, engine
from app.jobs.daily_yield import run_daily_yield_cron
def main(argv: list[str] | None = None) -> None:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
parser = argparse.ArgumentParser(description="按 UTC 日清算节点积分产出")
parser.add_argument("--date", dest="yield_date", help="清算日期 YYYY-MM-DD,默认上一个已结束的 UTC 日")
args = parser.parse_args(argv)
target = date.fromisoformat(args.yield_date) if args.yield_date else None
Base.metadata.create_all(bind=engine)
summary = run_daily_yield_cron(yield_date=target)
print(
f"yield_date={summary.yield_date} created={summary.created} "
f"skipped={summary.skipped} total_points={format(summary.total_points, 'f')} partial={summary.is_partial}"
)
if __name__ == "__main__":
main()
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# HyperNode-AI 交互式部署脚本,适用于 Ubuntu 22.04。
# 不安装 Nginx。反代由你自己配置,服务监听 0.0.0.0:33333。
#
# sudo bash deploy/ubuntu22.sh
set -euo pipefail
APP_USER="hypernode"
APP_DIR="/opt/hypernode"
DATA_DIR="/var/lib/hypernode"
ENV_FILE="${APP_DIR}/.env"
SERVICE_NAME="hypernode"
BIND_HOST="0.0.0.0"
BIND_PORT="33333"
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ "${EUID}" -ne 0 ]]; then
echo "请用 root 运行:sudo bash deploy/ubuntu22.sh" >&2
exit 1
fi
if [[ ! -t 0 ]]; then
echo "请在交互式终端中运行本脚本。" >&2
exit 1
fi
if [[ ! -r /etc/os-release ]]; then
echo "无法识别操作系统,这个脚本用于 Ubuntu 22.04。" >&2
exit 1
fi
# shellcheck disable=SC1091
source /etc/os-release
if [[ "${ID:-}" != "ubuntu" ]]; then
echo "当前系统是 ${ID:-unknown},这个脚本用于 Ubuntu 22.04。" >&2
exit 1
fi
if [[ "${VERSION_ID:-}" != "22.04" ]]; then
echo "警告:当前是 Ubuntu ${VERSION_ID:-unknown},脚本按 22.04 编写,将继续。" >&2
fi
if [[ ! -f "${SOURCE_DIR}/requirements.txt" || ! -f "${SOURCE_DIR}/app/main.py" ]]; then
echo "未在 ${SOURCE_DIR} 找到项目文件。" >&2
exit 1
fi
log() {
printf '\n==> %s\n' "$*"
}
pause() {
read -r -p "按回车返回菜单..." _ || exit 0
}
sync_code() {
mkdir -p "${APP_DIR}" "${DATA_DIR}"
if [[ "$(realpath "${SOURCE_DIR}")" != "$(realpath "${APP_DIR}")" ]]; then
rsync -a --delete \
--exclude '.venv/' \
--exclude '.env' \
--exclude '.git/' \
--exclude '__pycache__/' \
--exclude '*.db' \
--exclude '.pytest_cache/' \
"${SOURCE_DIR}/" "${APP_DIR}/"
fi
}
write_env() {
if [[ -f "${ENV_FILE}" ]]; then
log "保留已有配置 ${ENV_FILE}"
return
fi
log "写入 ${ENV_FILE}"
cat >"${ENV_FILE}" <<EOF
DATABASE_URL=sqlite:////${DATA_DIR}/hypernode.db
YIELD_TIMEZONE=Asia/Shanghai
YIELD_HOUR=0
YIELD_MINUTE=5
DISABLE_SCHEDULER=false
EOF
}
install_python_deps() {
if [[ ! -x "${APP_DIR}/.venv/bin/python" ]]; then
python3 -m venv "${APP_DIR}/.venv"
fi
"${APP_DIR}/.venv/bin/pip" install --upgrade pip
"${APP_DIR}/.venv/bin/pip" install -r "${APP_DIR}/requirements.txt"
}
write_service() {
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
[Unit]
Description=HyperNode-AI control plane
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}
EnvironmentFile=${ENV_FILE}
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONDONTWRITEBYTECODE=1
ExecStart=${APP_DIR}/.venv/bin/uvicorn app.main:app --host ${BIND_HOST} --port ${BIND_PORT} --workers 1
Restart=always
RestartSec=3
TimeoutStopSec=20
KillSignal=SIGINT
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
}
fix_permissions() {
chown -R "${APP_USER}:${APP_USER}" "${APP_DIR}" "${DATA_DIR}"
chmod 750 "${APP_DIR}" "${DATA_DIR}"
chmod 640 "${ENV_FILE}"
}
wait_until_ready() {
local ready=0
local _i
for _i in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://127.0.0.1:${BIND_PORT}/health" >/dev/null; then
ready=1
break
fi
sleep 1
done
if [[ "${ready}" -ne 1 ]]; then
echo "服务未在 10 秒内就绪。最近日志:" >&2
journalctl -u "${SERVICE_NAME}.service" -n 50 --no-pager >&2 || true
return 1
fi
}
ensure_user() {
if ! id -u "${APP_USER}" >/dev/null 2>&1; then
useradd --system --home-dir "${APP_DIR}" --shell /usr/sbin/nologin "${APP_USER}"
fi
}
install_packages() {
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y python3 python3-venv python3-pip rsync ca-certificates curl
python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'
}
cmd_deploy() {
log "一键部署"
install_packages
ensure_user
sync_code
write_env
fix_permissions
install_python_deps
fix_permissions
write_service
systemctl enable "${SERVICE_NAME}.service"
systemctl restart "${SERVICE_NAME}.service"
wait_until_ready
log "部署完成"
echo "监听地址:${BIND_HOST}:${BIND_PORT}"
echo "健康检查:curl http://127.0.0.1:${BIND_PORT}/health"
echo "接口文档:http://<服务器 IP>:${BIND_PORT}/docs"
echo "程序目录:${APP_DIR}"
echo "数据库:${DATA_DIR}/hypernode.db"
echo "日志:journalctl -u ${SERVICE_NAME}.service -f"
echo "反代请自行配置,上游地址使用 127.0.0.1:${BIND_PORT}"
}
cmd_update() {
if [[ ! -f "/etc/systemd/system/${SERVICE_NAME}.service" ]]; then
echo "尚未部署。请先选择「一键部署」。" >&2
return 1
fi
log "更新程序"
install_packages
sync_code
install_python_deps
fix_permissions
write_service
systemctl restart "${SERVICE_NAME}.service"
wait_until_ready
log "更新完成,配置和数据库已保留"
}
cmd_uninstall() {
local answer
echo "即将停止服务并删除 ${APP_DIR} 与 systemd 单元。"
read -r -p "确认卸载请输入 yes:" answer || exit 0
if [[ "${answer}" != "yes" ]]; then
echo "已取消卸载。"
return 0
fi
log "卸载服务"
systemctl stop "${SERVICE_NAME}.service" || true
systemctl disable "${SERVICE_NAME}.service" || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
rm -rf "${APP_DIR}"
read -r -p "同时删除数据库 ${DATA_DIR} ?输入 yes 删除,其他键保留:" answer || exit 0
if [[ "${answer}" == "yes" ]]; then
rm -rf "${DATA_DIR}"
echo "数据库已删除。"
else
echo "数据库已保留在 ${DATA_DIR}"
fi
if id -u "${APP_USER}" >/dev/null 2>&1; then
userdel "${APP_USER}" || echo "系统用户 ${APP_USER} 未删除,可稍后执行 userdel ${APP_USER}"
fi
log "卸载完成"
}
show_menu() {
cat <<EOF
========================================
HyperNode-AI 部署菜单 端口 ${BIND_PORT}
========================================
1) 一键部署
2) 一键卸载
3) 更新
4) 退出
========================================
EOF
}
while true; do
show_menu
read -r -p "请选择 [1-4]: " choice || exit 0
case "${choice}" in
1)
cmd_deploy || echo "部署失败。"
pause
;;
2)
cmd_uninstall || echo "卸载失败。"
pause
;;
3)
cmd_update || echo "更新失败。"
pause
;;
4)
echo "已退出。"
exit 0
;;
*)
echo "无效选项:${choice}"
;;
esac
done
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.115
uvicorn>=0.32
sqlalchemy>=2.0.30
apscheduler>=3.10.4,<4
pydantic>=2.6
pydantic-settings>=2.2
httpx>=0.27
tzdata>=2024.1
View File
+294
View File
@@ -0,0 +1,294 @@
"""核心链路回归:心跳超时、任务校验、产出公式、提现余额。"""
from __future__ import annotations
import os
import tempfile
import unittest
import uuid
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from pathlib import Path
_db_path = Path(tempfile.gettempdir()) / "depin_core_flow.db"
os.environ["DATABASE_URL"] = "sqlite:///" + _db_path.as_posix()
os.environ["DISABLE_SCHEDULER"] = "1"
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.clock import utcnow
from app.database import Base, SessionLocal, engine
from app.main import app
from app.jobs.daily_yield import run_daily_yield_cron
from app.models import (
ComputeNode,
ComputeTask,
NodeStatus,
NodeYield,
PushStatus,
TaskStatus,
UptimeInterval,
YieldStatus,
)
from app.services.heartbeat import mark_stale_nodes
from app.services.tasks import dispatch_random_tasks
class CoreFlowTest(unittest.TestCase):
def setUp(self) -> None:
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
self.client_cm = TestClient(app)
self.client = self.client_cm.__enter__()
def tearDown(self) -> None:
self.client_cm.__exit__(None, None, None)
def test_heartbeat_goes_offline_after_timeout(self) -> None:
self._register("0xabc", "NODE-A", "100")
beat = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
self.assertEqual(beat.status_code, 200, beat.text)
self.assertEqual(beat.json()["status"], "online")
recent = utcnow() - timedelta(minutes=1)
self._shift_presence("NODE-A", recent)
with SessionLocal() as session:
mark_stale_nodes(session, utcnow())
session.commit()
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
self.assertEqual(node.status, NodeStatus.ONLINE)
stale_at = utcnow() - timedelta(minutes=30)
self._shift_presence("NODE-A", stale_at)
with SessionLocal() as session:
changed = mark_stale_nodes(session, utcnow())
session.commit()
self.assertEqual(changed, ["NODE-A"])
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
interval = session.scalar(select(UptimeInterval).where(UptimeInterval.node_id == node.id))
self.assertEqual(node.status, NodeStatus.OFFLINE)
self.assertEqual(interval.ended_at, node.last_heartbeat_at)
self.assertAlmostEqual((utcnow() - interval.ended_at).total_seconds(), 1800, delta=5)
self.assertEqual(interval.close_reason, "heartbeat_timeout")
expired = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
self.assertEqual(expired.status_code, 200, expired.text)
with SessionLocal() as session:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-A"))
node.license_expires_at = utcnow() - timedelta(minutes=1)
session.commit()
denied = self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-A"})
self.assertEqual(denied.status_code, 403, denied.text)
def test_task_report_requires_nonce(self) -> None:
self._register("0xdef", "NODE-T", "50")
self.client.post("/api/v1/node/heartbeat", json={"sn": "NODE-T"})
with SessionLocal() as session:
tasks = dispatch_random_tasks(session)
session.commit()
task_id = tasks[0].id
nonce = tasks[0].payload["nonce"]
pending = self.client.get("/api/v1/node/NODE-T/tasks")
self.assertEqual(pending.status_code, 200, pending.text)
self.assertEqual(pending.json()[0]["task_id"], task_id)
rejected = self.client.post(
"/api/v1/node/task/report",
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": "0" * 32},
)
self.assertEqual(rejected.status_code, 400, rejected.text)
accepted = self.client.post(
"/api/v1/node/task/report",
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": nonce},
)
self.assertEqual(accepted.status_code, 200, accepted.text)
self.assertEqual(accepted.json()["status"], "completed")
duplicated = self.client.post(
"/api/v1/node/task/report",
json={"sn": "NODE-T", "task_id": task_id, "success": True, "nonce": nonce},
)
self.assertEqual(duplicated.status_code, 409, duplicated.text)
def test_daily_yield_uses_uptime_and_task_response(self) -> None:
yesterday = (utcnow() - timedelta(days=1)).date()
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
day_end = day_start + timedelta(days=1)
for wallet, sn in (("0xaaa", "NODE-FULL"), ("0xbbb", "NODE-NOTASK"), ("0xccc", "NODE-MISS")):
self._register(wallet, sn, "365")
self._cover_full_day(sn, day_start, day_end)
with SessionLocal() as session:
full = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-FULL"))
miss = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-MISS"))
session.add(self._task(full.id, TaskStatus.COMPLETED, day_start))
session.add(self._task(miss.id, TaskStatus.FAILED, day_start))
session.commit()
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
self.assertEqual(settled.status_code, 200, settled.text)
self.assertEqual(settled.json()["created"], 3)
full_row = self._yield_row("NODE-FULL")
idle_row = self._yield_row("NODE-NOTASK")
miss_row = self._yield_row("NODE-MISS")
self.assertEqual(full_row.online_rate, Decimal("1.00000000"))
self.assertEqual(full_row.task_response_rate, Decimal("1.00000000"))
self.assertEqual(full_row.performance, Decimal("1.00000000"))
self.assertEqual(full_row.points_delta, Decimal("1.00000000"))
self.assertEqual(full_row.compute_output, Decimal("365.00000000"))
self.assertEqual(full_row.online_weight, Decimal("0.70000000"))
self.assertEqual(idle_row.tasks_dispatched, 0)
self.assertEqual(idle_row.performance, Decimal("1.00000000"))
self.assertEqual(idle_row.points_delta, Decimal("1.00000000"))
self.assertEqual(idle_row.online_weight, Decimal("1.00000000"))
self.assertEqual(miss_row.task_response_rate, Decimal("0.00000000"))
self.assertEqual(miss_row.performance, Decimal("0.70000000"))
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
again = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
self.assertEqual(again.json()["created"], 0)
self.assertEqual(again.json()["skipped"], 3)
future = self.client.post("/api/v1/yield/settle", params={"yield_date": "2999-01-01"})
self.assertEqual(future.status_code, 400, future.text)
def test_open_interval_stops_at_last_heartbeat(self) -> None:
yesterday = (utcnow() - timedelta(days=1)).date()
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
self._register("0xhalf", "NODE-HALF", "365")
noon = day_start + timedelta(hours=12)
with SessionLocal() as session:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-HALF"))
node.licensed_at = day_start
node.license_expires_at = day_start + timedelta(days=365)
node.last_heartbeat_at = noon
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=None))
session.commit()
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
self.assertEqual(settled.status_code, 200, settled.text)
row = self._yield_row("NODE-HALF")
self.assertEqual(row.online_seconds, 12 * 60 * 60)
self.assertEqual(row.online_rate, Decimal("0.50000000"))
self.assertEqual(row.points_delta, Decimal("0.50000000"))
def test_daily_cron_writes_previous_closed_day(self) -> None:
# 北京时间 2026-10-12 00:05,对应上一个已结束的 UTC 账本日 2026-10-10。
moment = datetime(2026, 10, 11, 16, 5, tzinfo=timezone.utc)
day_start = datetime(2026, 10, 10, tzinfo=timezone.utc)
self._register("0xcron", "NODE-CRON", "365")
self._cover_full_day("NODE-CRON", day_start, day_start + timedelta(days=1))
summary = run_daily_yield_cron(now=moment)
self.assertEqual(summary.yield_date.isoformat(), "2026-10-10")
self.assertEqual(summary.created, 1)
self.assertFalse(summary.is_partial)
row = self._yield_row("NODE-CRON")
self.assertEqual(row.status, YieldStatus.SETTLED)
self.assertEqual(row.points_delta, Decimal("1.00000000"))
self.assertEqual(row.compute_output, Decimal("365.00000000"))
again = run_daily_yield_cron(now=moment)
self.assertEqual(again.created, 0)
self.assertEqual(again.skipped, 1)
def test_withdrawal_reserves_balance_until_rejected_or_confirmed(self) -> None:
yesterday = (utcnow() - timedelta(days=1)).date()
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
self._register("0xpay", "NODE-PAY", "365")
self._cover_full_day("NODE-PAY", day_start, day_start + timedelta(days=1))
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
self.assertEqual(settled.status_code, 200, settled.text)
too_much = self.client.post(
"/api/v1/withdrawals",
json={"wallet_address": "0xpay", "amount": "10"},
)
self.assertEqual(too_much.status_code, 400, too_much.text)
created = self.client.post(
"/api/v1/withdrawals",
json={"wallet_address": "0xpay", "amount": "0.4", "asset_symbol": "POINT"},
)
self.assertEqual(created.status_code, 200, created.text)
withdrawal_id = created.json()["id"]
self.assertEqual(created.json()["available_after"], "0.60000000")
blocked = self.client.post(
"/api/v1/withdrawals",
json={"wallet_address": "0xpay", "amount": "0.7"},
)
self.assertEqual(blocked.status_code, 400, blocked.text)
confirmed = self.client.post(
f"/api/v1/withdrawals/{withdrawal_id}/confirm",
json={"tx_hash": "0xabc123"},
)
self.assertEqual(confirmed.status_code, 200, confirmed.text)
self.assertEqual(confirmed.json()["status"], "completed")
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
balance = self.client.get("/api/v1/users/0xpay/balance")
self.assertEqual(balance.json()["available_points"], "0.60000000")
def _register(self, wallet: str, sn: str, amount: str) -> None:
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
self.assertEqual(user.status_code, 200, user.text)
node = self.client.post(
"/api/v1/nodes",
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
)
self.assertEqual(node.status_code, 200, node.text)
self.assertEqual(node.json()["status"], "offline")
self.assertEqual(node.json()["license_months"], 12)
def _shift_presence(self, sn: str, moment) -> None:
with SessionLocal() as session:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
node.last_heartbeat_at = moment
interval = session.scalar(
select(UptimeInterval).where(
UptimeInterval.node_id == node.id,
UptimeInterval.ended_at.is_(None),
)
)
interval.started_at = moment
session.commit()
def _cover_full_day(self, sn: str, day_start, day_end) -> None:
with SessionLocal() as session:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
node.licensed_at = day_start
node.license_expires_at = day_start + timedelta(days=365)
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=day_end))
session.commit()
def _task(self, node_id: int, status: TaskStatus, day_start) -> ComputeTask:
return ComputeTask(
id=str(uuid.uuid4()),
node_id=node_id,
task_type="simulated_compute",
payload={"op": "simulated_compute", "nonce": "abc123abc123abcd", "seed": "seedseed"},
status=status,
push_status=PushStatus.QUEUED,
dispatched_at=day_start + timedelta(hours=3),
deadline_at=day_start + timedelta(hours=4),
success=status == TaskStatus.COMPLETED,
)
def _yield_row(self, sn: str) -> NodeYield:
with SessionLocal() as session:
node = session.scalar(select(ComputeNode).where(ComputeNode.sn == sn))
return session.scalar(select(NodeYield).where(NodeYield.node_id == node.id))
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
# HyperNode-AI 说明
HyperNode-AI 是算力节点的控制面。节点程序部署在各处,本服务负责登记节点、接收心跳、下发模拟任务、按天计算积分,并记录提现。
时间全部使用 UTC。每日清算的触发时刻是北京时间 00:05,入账的是上一个已经结束的 UTC 日。
本地开发直接启动时监听 8000。服务器按 [部署.md](部署.md) 安装后监听 33333。
```bash
pip install -r requirements.txt
python -m app
```
## 目录
| 路径 | 内容 |
|---|---|
| `app/models.py` | 数据表 |
| `app/api.py` | HTTP 接口 |
| `app/services/heartbeat.py` | 心跳与离线判断 |
| `app/services/tasks.py` | 任务下发与 nonce 校验 |
| `app/services/yield_engine.py` | 产出公式与按日入账 |
| `app/jobs/daily_yield.py` | 凌晨清算入口 |
| `app/scheduler.py` | 进程内定时任务 |
| `app/settle.py` | 可单独执行的清算命令 |
| `deploy/ubuntu22.sh` | Ubuntu 22.04 交互式部署 |
## 数据表
业务表:
- `users`:钱包地址、注册时间。
- `compute_nodes`:节点 SN、购买金额、12 个月授权、`online` / `offline`、最后心跳。可选 `callback_url` 用来推送任务。
- `node_yields`:每日算力产出和积分。同一节点同一天只有一条。
- `withdrawal_logs`:提现金额、资产符号、链上哈希、状态。
计算用的表:
- `uptime_intervals`:一段连续在线时间。
- `compute_tasks`:下发的模拟任务和回传结果。
- `scheduler_locks`:多台服务实例同时跑定时任务时互斥。
## 产出公式
授权金额按授权起止日期的天数线性释放,再乘当天表现系数。单节点累计积分不超过购买金额。
- 在线率 = 当天授权窗口内的在线秒数 / 当天授权有效秒数
- 任务响应率 = 校验通过且执行成功的任务数 / 当天下发任务数
- 当天没有下发任务时,表现系数 = 在线率
- 有任务时,表现系数 = `0.7 × 在线率 + 0.3 × 响应率`
- 算力产出 = 购买金额 × 表现系数
- 当日积分 = 算力产出 / 授权天数
开放的在线区间如果最后心跳已超过 15 分钟,只计算到最后一次心跳。
权重可用环境变量 `ONLINE_WEIGHT`、`TASK_WEIGHT` 修改,默认 `0.7` 和 `0.3`。
## 运行中的三件事
服务进程里有三个定时任务:
1. 每 5 分钟检查心跳。超过 15 分钟没有心跳,或授权到期,节点改为 `offline`,在线区间结束在最后一次心跳。
2. 每 5 分钟向在线、授权仍有效、且没有未完成任务的节点随机下发任务,每轮最多 20 台。任务带 `task_id` 和一次性 `nonce`。节点若配置了 `callback_url`,会额外 POST 一次;否则节点用拉取接口领取。
3. 北京时间 00:05 结算上一个已结束的 UTC 日,写入 `node_yields`。同一天已有记录则跳过。
回传必须带上任务里的 `nonce`。超时、重复回传、nonce 不匹配都不算成功。
## 接口
前缀都是 `/api/v1`。金额在 JSON 里是字符串。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | `/users` | 注册钱包 |
| POST | `/nodes` | 登记节点 |
| GET | `/nodes/{sn}` | 查看节点 |
| POST | `/node/heartbeat` | 心跳,请求体 `{"sn":"..."}` |
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
| POST | `/node/task/report` | 回传 `sn`、`task_id`、`success`、`nonce` |
| GET | `/nodes/{sn}/contribution?yield_date=YYYY-MM-DD` | 预览某日产出,不入账 |
| POST | `/yield/settle?yield_date=YYYY-MM-DD` | 手动清算,不传日期则清算昨天 |
| GET | `/nodes/{sn}/yields` | 节点流水 |
| GET | `/users/{wallet}/balance` | 可用积分 |
| POST | `/withdrawals` | 申请提现 |
| POST | `/withdrawals/{id}/confirm` | 写入 `tx_hash` 并完成 |
| POST | `/withdrawals/{id}/reject` | 拒绝并释放占用的积分 |
可用积分 = 已入账积分 − 待处理提现 − 已完成提现。
健康检查是 `/health`,不在 `/api/v1` 下面。
## 配置
生产环境的配置在 `/opt/hypernode/.env`。也可以在启动前用同名环境变量覆盖。
| 变量 | 默认值 | 含义 |
|---|---|---|
| `DATABASE_URL` | 部署后指向 `/var/lib/hypernode/hypernode.db` | 数据库。可改为 PostgreSQL 连接串 |
| `HEARTBEAT_TIMEOUT_MINUTES` | `15` | 心跳超时 |
| `OFFLINE_CHECK_MINUTES` | `5` | 离线巡检间隔 |
| `TASK_DISPATCH_MINUTES` | `5` | 任务下发间隔 |
| `DISPATCH_BATCH_SIZE` | `20` | 每轮最多下发数量 |
| `TASK_DEADLINE_MINUTES` | `10` | 任务回传时限 |
| `ONLINE_WEIGHT` / `TASK_WEIGHT` | `0.7` / `0.3` | 有任务时的表现系数权重 |
| `YIELD_TIMEZONE` | `Asia/Shanghai` | 清算触发时区 |
| `YIELD_HOUR` / `YIELD_MINUTE` | `0` / `5` | 清算时刻 |
| `LICENSE_MONTHS` | `12` | 新节点授权月数 |
| `DISABLE_SCHEDULER` | `false` | 设为 `true` 时不启动定时任务 |
修改 `.env` 后执行 `systemctl restart hypernode`。
+92
View File
@@ -0,0 +1,92 @@
# HyperNode-AI 部署说明
适用于 Ubuntu 22.04。脚本只安装应用本身,监听 `0.0.0.0:33333`。Nginx、Caddy 或其他反代请自行配置,上游地址使用 `127.0.0.1:33333`。
## 1. 把代码放到服务器
仓库地址:[https://git.bz121.com/dekun/HyperNode-AI.git](https://git.bz121.com/dekun/HyperNode-AI.git)
```bash
git clone https://git.bz121.com/dekun/HyperNode-AI.git
cd HyperNode-AI
sudo bash deploy/ubuntu22.sh
```
私有仓库克隆时需要你的 Gitea 账号。也可以用 scp 把整个项目目录上传到服务器,再进入该目录执行同一条命令。
## 2. 菜单
脚本是交互式的,启动后只有四个选项:
| 选项 | 作用 |
|---|---|
| 1 一键部署 | 安装 Python 依赖、创建系统用户、写入 systemd,并启动服务 |
| 2 一键卸载 | 停止服务并删除程序。数据库要再输入一次 `yes` 才会删除 |
| 3 更新 | 用当前目录的代码覆盖 `/opt/hypernode`,重装依赖并重启。`.env` 和数据库保留 |
| 4 退出 | 离开脚本 |
部署和更新结束时会请求 `http://127.0.0.1:33333/health`,失败会打印最近 50 行服务日志。
重复执行「一键部署」不会覆盖已经存在的 `/opt/hypernode/.env`,也不会删除数据库。
## 3. 安装结果
| 项目 | 位置 |
|---|---|
| 程序 | `/opt/hypernode` |
| 配置 | `/opt/hypernode/.env` |
| 虚拟环境 | `/opt/hypernode/.venv` |
| 数据库 | `/var/lib/hypernode/hypernode.db` |
| systemd | `hypernode.service` |
| 监听 | `0.0.0.0:33333`,进程数固定为 1 |
进程数必须是 1。每日清算跑在这个进程里,开多个 worker 会重复触发定时任务。
常用命令:
```bash
systemctl status hypernode
journalctl -u hypernode.service -f
curl http://127.0.0.1:33333/health
```
接口文档:`http://<服务器 IP>:33333/docs`
手动补跑某一天的清算:
```bash
sudo -u hypernode /opt/hypernode/.venv/bin/python -m app.settle --date 2026-10-09
```
不带日期时,结算上一个已经结束的 UTC 账本日。服务正常运行时,北京时间每天 00:05 会自动执行。
## 4. 反代
脚本不修改 Nginx。同一台机器上的反代把流量转到本机 33333 即可,例如:
```nginx
location / {
proxy_pass http://127.0.0.1:33333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
当前接口没有登录鉴权。反代若只在本机使用,云安全组不要对公网放行 33333。需要从外网直接访问时,再放行该端口,并在反代或安全组上限制来源。
## 5. 更新代码
在服务器上进入新的项目目录后重新打开菜单,选择 `3`:
```bash
cd /path/to/HyperNode-AI
sudo bash deploy/ubuntu22.sh
```
选择更新后,脚本把该目录同步到 `/opt/hypernode`,然后重启服务。
## 6. 卸载
菜单选择 `2`。先输入 `yes` 确认删除程序。是否删除 `/var/lib/hypernode` 会再问一次;直接回车会留下数据库。