初始化 HyperNode-AI 控制面。
包含节点账本、每日清算,以及 Ubuntu 交互式部署脚本。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""DePIN 算力节点与任务调度控制面。"""
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""后台定时任务。"""
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
"""业务服务。"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user