初始化 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
+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()