按显卡世代和创世编号结算回本,机房批量接入需审核网段并用硬件指纹防克隆。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+132
-11
@@ -14,7 +14,17 @@ from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.deps import AdminUser, CurrentUser
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, ComputeTask, LicenseOrder, User, UserRole, WithdrawalLog, WithdrawalStatus
|
||||
from app.models import (
|
||||
ComputeNode,
|
||||
ComputeTask,
|
||||
DatacenterApplication,
|
||||
IPWhitelist,
|
||||
LicenseOrder,
|
||||
User,
|
||||
UserRole,
|
||||
WithdrawalLog,
|
||||
WithdrawalStatus,
|
||||
)
|
||||
from app.money import money
|
||||
from app.schemas import (
|
||||
AdminUserOut,
|
||||
@@ -32,6 +42,9 @@ from app.schemas import (
|
||||
NodeCreate,
|
||||
NodeOut,
|
||||
NodeView,
|
||||
IPWhitelistCreate,
|
||||
IPWhitelistOut,
|
||||
WhitelistToggle,
|
||||
RegisterIn,
|
||||
SettleOut,
|
||||
SummaryOut,
|
||||
@@ -43,6 +56,9 @@ from app.schemas import (
|
||||
WithdrawalConfirm,
|
||||
WithdrawalCreate,
|
||||
WithdrawalOut,
|
||||
DatacenterApply,
|
||||
DatacenterOut,
|
||||
DatacenterView,
|
||||
WithdrawalReject,
|
||||
WithdrawalView,
|
||||
YieldOut,
|
||||
@@ -50,7 +66,11 @@ from app.schemas import (
|
||||
from app.security import create_access_token
|
||||
from app.services.auth import authenticate, register_account
|
||||
from app.services.common import get_node_by_sn, get_user_by_wallet
|
||||
from app.services.datacenter import approve_application, list_applications, reject_application, submit_application
|
||||
from app.services.hardware import HardwareReport
|
||||
from app.services.hardware import display_node_id, displayed_population
|
||||
from app.services.heartbeat import assert_node_token, record_heartbeat
|
||||
from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip
|
||||
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
|
||||
from app.services.portal import (
|
||||
authorized_amount,
|
||||
@@ -63,7 +83,7 @@ from app.services.portal import (
|
||||
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
|
||||
from app.services.yield_engine import compute_node_yield, list_node_yields, network_yields, settle_yield_date
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
Db = Annotated[Session, Depends(get_db)]
|
||||
@@ -81,6 +101,12 @@ def login(body: LoginIn, db: Db) -> TokenOut:
|
||||
return _token(user)
|
||||
|
||||
|
||||
@router.get("/network")
|
||||
def network_population(db: Db) -> dict[str, int]:
|
||||
nodes, online = node_counts(db)
|
||||
return {"nodes": displayed_population(nodes), "online": displayed_population(online)}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(user: CurrentUser) -> User:
|
||||
return user
|
||||
@@ -93,6 +119,7 @@ def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
|
||||
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
|
||||
).all()
|
||||
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
|
||||
network_total, network_online = node_counts(db)
|
||||
return DashboardOut(
|
||||
wallet_address=user.wallet_address,
|
||||
username=user.username,
|
||||
@@ -104,7 +131,10 @@ def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
|
||||
orders=[_order_out(item) for item in list_orders(db, user.id)],
|
||||
license_price=money(settings.license_price),
|
||||
license_asset=settings.license_asset,
|
||||
network_nodes=displayed_population(network_total),
|
||||
network_online=displayed_population(network_online),
|
||||
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
|
||||
datacenter_applications=[_datacenter_out(item) for item in list_applications(db, user.id)],
|
||||
)
|
||||
|
||||
|
||||
@@ -128,8 +158,25 @@ def download_client(sn: str, request: Request, user: CurrentUser, db: Db) -> Res
|
||||
|
||||
|
||||
@router.post("/me/nodes", response_model=NodeOut)
|
||||
def create_my_node(body: MeNodeCreate, user: CurrentUser, db: Db) -> NodeOut:
|
||||
return register_node(db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url)
|
||||
def create_my_node(body: MeNodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
|
||||
return register_node(
|
||||
db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/datacenter-applications", response_model=DatacenterOut)
|
||||
def create_datacenter_application(body: DatacenterApply, user: CurrentUser, db: Db) -> DatacenterOut:
|
||||
return _datacenter_out(
|
||||
submit_application(
|
||||
db,
|
||||
user.id,
|
||||
body.contact,
|
||||
body.node_count,
|
||||
body.subnet_ip,
|
||||
body.hardware_proof,
|
||||
body.gpu_generation,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/withdrawals", response_model=WithdrawalOut)
|
||||
@@ -153,6 +200,46 @@ def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
||||
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
||||
|
||||
|
||||
@router.post("/admin/nodes/{sn}/whitelist", response_model=NodeView)
|
||||
def admin_toggle_node_whitelist(sn: str, body: WhitelistToggle, _: AdminUser, db: Db) -> NodeView:
|
||||
node = get_node_by_sn(db, sn)
|
||||
node.is_whitelist = body.is_whitelist
|
||||
node.updated_at = utcnow()
|
||||
db.flush()
|
||||
snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
|
||||
return NodeView.model_validate(snapshot)
|
||||
|
||||
|
||||
@router.get("/admin/ip-whitelist", response_model=list[IPWhitelistOut])
|
||||
def admin_ip_whitelist(_: AdminUser, db: Db) -> list[IPWhitelist]:
|
||||
return list_whitelist(db)
|
||||
|
||||
|
||||
@router.post("/admin/ip-whitelist", response_model=IPWhitelistOut)
|
||||
def admin_add_ip_whitelist(body: IPWhitelistCreate, _: AdminUser, db: Db) -> IPWhitelist:
|
||||
return add_whitelist_ip(db, body.ip_address, body.note)
|
||||
|
||||
|
||||
@router.delete("/admin/ip-whitelist/{entry_id}", response_model=IPWhitelistOut)
|
||||
def admin_remove_ip_whitelist(entry_id: int, _: AdminUser, db: Db) -> IPWhitelist:
|
||||
return remove_whitelist_ip(db, entry_id)
|
||||
|
||||
|
||||
@router.get("/admin/datacenter-applications", response_model=list[DatacenterView])
|
||||
def admin_datacenter_applications(_: AdminUser, db: Db) -> list[DatacenterView]:
|
||||
return [_datacenter_view(row) for row in list_applications(db)]
|
||||
|
||||
|
||||
@router.post("/admin/datacenter-applications/{application_id}/approve", response_model=DatacenterView)
|
||||
def admin_approve_datacenter(application_id: int, _: AdminUser, db: Db) -> DatacenterView:
|
||||
return _datacenter_view(approve_application(db, application_id))
|
||||
|
||||
|
||||
@router.post("/admin/datacenter-applications/{application_id}/reject", response_model=DatacenterView)
|
||||
def admin_reject_datacenter(application_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> DatacenterView:
|
||||
return _datacenter_view(reject_application(db, application_id, body.note))
|
||||
|
||||
|
||||
@router.get("/admin/orders", response_model=list[OrderView])
|
||||
def admin_orders(_: AdminUser, db: Db) -> list[OrderView]:
|
||||
return [_order_view(row) for row in list_orders(db)]
|
||||
@@ -204,9 +291,11 @@ def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
||||
|
||||
|
||||
@router.post("/nodes", response_model=NodeOut)
|
||||
def create_node(body: NodeCreate, user: CurrentUser, db: Db) -> NodeOut:
|
||||
def create_node(body: NodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
|
||||
_must_own_wallet(user, body.wallet_address)
|
||||
return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
|
||||
return register_node(
|
||||
db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/nodes/{sn}", response_model=NodeOut)
|
||||
@@ -215,13 +304,28 @@ def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
|
||||
|
||||
|
||||
@router.post("/node/heartbeat", response_model=HeartbeatOut)
|
||||
def heartbeat(body: HeartbeatIn, db: Db) -> HeartbeatOut:
|
||||
node = record_heartbeat(db, body.sn, body.token)
|
||||
def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
|
||||
hardware = None
|
||||
if {"gpu_model", "vram_gb", "cpu_count", "memory_gb"} & body.model_fields_set:
|
||||
hardware = HardwareReport(body.gpu_model, body.vram_gb, body.cpu_count, body.memory_gb)
|
||||
node = record_heartbeat(
|
||||
db,
|
||||
body.sn,
|
||||
body.token,
|
||||
hardware=hardware,
|
||||
client_ip=client_ip(request),
|
||||
hardware_fingerprint=body.hardware_fingerprint,
|
||||
)
|
||||
return HeartbeatOut(
|
||||
sn=node.sn,
|
||||
display_node_id=node.display_node_id,
|
||||
status=node.status,
|
||||
last_heartbeat_at=node.last_heartbeat_at,
|
||||
server_time=node.last_heartbeat_at,
|
||||
hardware_weight=node.hardware_weight,
|
||||
gpu_generation=node.gpu_generation,
|
||||
recovery_days=node.recovery_days,
|
||||
recoverable_stake_amount=node.recoverable_stake_amount,
|
||||
)
|
||||
|
||||
|
||||
@@ -240,7 +344,12 @@ def report(body: TaskReportIn, db: Db) -> TaskOut:
|
||||
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
|
||||
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
|
||||
node = _owned_node(db, user, sn)
|
||||
computed = compute_node_yield(db, node, yield_date)
|
||||
computed = next(
|
||||
(item for current, item in network_yields(db, yield_date) if current.id == node.id),
|
||||
None,
|
||||
)
|
||||
if computed is None:
|
||||
computed = compute_node_yield(db, node, yield_date)
|
||||
return ContributionOut(sn=node.sn, **computed.__dict__)
|
||||
|
||||
|
||||
@@ -297,8 +406,8 @@ def _summary(db: Session) -> SummaryOut:
|
||||
nodes, online = node_counts(db)
|
||||
return SummaryOut(
|
||||
users=users,
|
||||
nodes=nodes,
|
||||
online=online,
|
||||
nodes=displayed_population(nodes),
|
||||
online=displayed_population(online),
|
||||
offline=nodes - online,
|
||||
authorized_amount=authorized_amount(db),
|
||||
settled_points=settled_points(db),
|
||||
@@ -338,6 +447,17 @@ def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
|
||||
)
|
||||
|
||||
|
||||
def _datacenter_out(row: DatacenterApplication) -> DatacenterOut:
|
||||
return DatacenterOut.model_validate(row)
|
||||
|
||||
|
||||
def _datacenter_view(row: DatacenterApplication) -> DatacenterView:
|
||||
return DatacenterView(
|
||||
**_datacenter_out(row).model_dump(),
|
||||
wallet_address=row.user.wallet_address,
|
||||
)
|
||||
|
||||
|
||||
def _order_out(order: LicenseOrder) -> OrderOut:
|
||||
return OrderOut(
|
||||
id=order.id,
|
||||
@@ -349,6 +469,7 @@ def _order_out(order: LicenseOrder) -> OrderOut:
|
||||
created_at=order.created_at,
|
||||
reviewed_at=order.reviewed_at,
|
||||
sn=order.node.sn if order.node is not None else None,
|
||||
display_node_id=display_node_id(order.node.node_index) if order.node is not None else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ from sqlalchemy import (
|
||||
Enum,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Boolean,
|
||||
Float,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
@@ -35,6 +37,7 @@ from sqlalchemy.types import JSON, TypeDecorator
|
||||
from app.clock import utcnow
|
||||
from app.database import Base, UtcDateTime
|
||||
from app.money import money
|
||||
from app.services.hardware import display_node_id, is_genesis
|
||||
|
||||
|
||||
class PreciseDecimal(TypeDecorator):
|
||||
@@ -109,6 +112,12 @@ class OrderStatus(str, enum.Enum):
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class DatacenterStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
|
||||
|
||||
@@ -125,6 +134,7 @@ class User(Base):
|
||||
yields: Mapped[list[NodeYield]] = relationship(back_populates="user")
|
||||
withdrawals: Mapped[list[WithdrawalLog]] = relationship(back_populates="user")
|
||||
orders: Mapped[list[LicenseOrder]] = relationship(back_populates="user")
|
||||
datacenter_applications: Mapped[list[DatacenterApplication]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class ComputeNode(Base):
|
||||
@@ -140,6 +150,7 @@ class ComputeNode(Base):
|
||||
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="节点唯一序列号")
|
||||
node_index: Mapped[int] = mapped_column(Integer, unique=True, comment="激活顺序,从 1 开始")
|
||||
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="授权开始时间")
|
||||
@@ -148,16 +159,69 @@ class ComputeNode(Base):
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
|
||||
callback_url: Mapped[str | None] = mapped_column(String(512), comment="可选的任务推送地址")
|
||||
node_token: Mapped[str | None] = mapped_column(String(128), unique=True, comment="挂机客户端凭证,审核通过后生成")
|
||||
gpu_model: Mapped[str | None] = mapped_column(String(128), comment="心跳上报的显卡型号")
|
||||
vram_gb: Mapped[float | None] = mapped_column(Float, comment="显存,GB")
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, comment="CPU 逻辑核心数")
|
||||
memory_gb: Mapped[float | None] = mapped_column(Float, comment="内存,GB")
|
||||
hardware_weight: Mapped[float] = mapped_column(Float, default=0.05, comment="硬件算力权重")
|
||||
gpu_generation: Mapped[str] = mapped_column(String(8), default="10", comment="显卡世代:40、30 或 10")
|
||||
recovery_days: Mapped[int] = mapped_column(Integer, default=540, comment="质押回本目标天数")
|
||||
generation_locked: Mapped[bool] = mapped_column(default=False, comment="世代已按上报显卡锁定")
|
||||
recoverable_stake_amount: Mapped[Decimal | None] = mapped_column(MONEY, comment="尚未返还的质押金")
|
||||
is_whitelist: Mapped[bool] = mapped_column(Boolean, default=False, comment="白名单节点,跳过单 IP 限制")
|
||||
client_ip: Mapped[str | None] = mapped_column(String(64), index=True, comment="最近一次注册或心跳的客户端 IP")
|
||||
hardware_fingerprint: Mapped[str | None] = mapped_column(
|
||||
String(64), unique=True, comment="显卡 UUID、主板与网卡合成的硬件指纹"
|
||||
)
|
||||
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")
|
||||
order: Mapped[LicenseOrder | None] = relationship(back_populates="node")
|
||||
|
||||
@property
|
||||
def display_node_id(self) -> int:
|
||||
return display_node_id(self.node_index)
|
||||
|
||||
@property
|
||||
def genesis(self) -> bool:
|
||||
return is_genesis(self.node_index)
|
||||
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 IPWhitelist(Base):
|
||||
"""管理员维护的豁免地址。可以是单 IP,也可以是机房网段,例如 203.0.113.0/24。"""
|
||||
|
||||
__tablename__ = "ip_whitelist"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
ip_address: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
note: Mapped[str | None] = mapped_column(String(128))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
|
||||
|
||||
|
||||
class DatacenterApplication(Base):
|
||||
"""机房大客户申请。通过后把出口网段写入 IP 白名单。"""
|
||||
|
||||
__tablename__ = "datacenter_applications"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
contact: Mapped[str] = mapped_column(String(128))
|
||||
node_count: Mapped[int] = mapped_column(Integer)
|
||||
subnet_ip: Mapped[str] = mapped_column(String(64))
|
||||
hardware_proof: Mapped[str] = mapped_column(Text)
|
||||
gpu_generation: Mapped[str] = mapped_column(String(8))
|
||||
status: Mapped[DatacenterStatus] = mapped_column(_enum(DatacenterStatus), default=DatacenterStatus.PENDING, index=True)
|
||||
note: Mapped[str | None] = mapped_column(String(500))
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="datacenter_applications")
|
||||
|
||||
|
||||
class UptimeInterval(Base):
|
||||
"""一段连续在线时间。心跳首次上线时打开,超时或授权到期时关闭。"""
|
||||
|
||||
|
||||
+55
-1
@@ -10,6 +10,7 @@ def upgrade_schema() -> None:
|
||||
tables = set(inspector.get_table_names())
|
||||
statements: list[str] = []
|
||||
need_username_index = False
|
||||
node_columns: set[str] = set()
|
||||
if "users" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "username" not in columns:
|
||||
@@ -26,13 +27,66 @@ def upgrade_schema() -> None:
|
||||
statements.append(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_compute_nodes_node_token ON compute_nodes (node_token)"
|
||||
)
|
||||
if not statements and not need_username_index:
|
||||
if "gpu_model" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN gpu_model VARCHAR(128)")
|
||||
if "vram_gb" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN vram_gb FLOAT")
|
||||
if "cpu_count" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN cpu_count INTEGER")
|
||||
if "memory_gb" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN memory_gb FLOAT")
|
||||
if "hardware_weight" not in node_columns:
|
||||
statements.append(
|
||||
"ALTER TABLE compute_nodes ADD COLUMN hardware_weight FLOAT DEFAULT 0.05 NOT NULL"
|
||||
)
|
||||
if "gpu_generation" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN gpu_generation VARCHAR(8) DEFAULT '10' NOT NULL")
|
||||
if "recovery_days" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN recovery_days INTEGER DEFAULT 540 NOT NULL")
|
||||
if "generation_locked" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN generation_locked BOOLEAN DEFAULT 0 NOT NULL")
|
||||
if "recoverable_stake_amount" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN recoverable_stake_amount NUMERIC(24, 8)")
|
||||
if "node_index" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN node_index INTEGER")
|
||||
if "is_whitelist" not in node_columns:
|
||||
statements.append(
|
||||
"ALTER TABLE compute_nodes ADD COLUMN is_whitelist BOOLEAN DEFAULT 0 NOT NULL"
|
||||
)
|
||||
if "client_ip" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN client_ip VARCHAR(64)")
|
||||
statements.append(
|
||||
"CREATE INDEX IF NOT EXISTS ix_compute_nodes_client_ip ON compute_nodes (client_ip)"
|
||||
)
|
||||
if "hardware_fingerprint" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN hardware_fingerprint VARCHAR(64)")
|
||||
statements.append(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_compute_nodes_hardware_fingerprint "
|
||||
"ON compute_nodes (hardware_fingerprint)"
|
||||
)
|
||||
index_ready = "compute_nodes" in tables and ("node_index" in node_columns or any("node_index" in item for item in statements))
|
||||
if not statements and not need_username_index and not index_ready:
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for statement in statements:
|
||||
conn.execute(text(statement))
|
||||
if need_username_index:
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
|
||||
if index_ready:
|
||||
_backfill_node_index(conn, "recovery_days" in node_columns or any("recovery_days" in item for item in statements))
|
||||
|
||||
|
||||
def _backfill_node_index(conn, has_recovery: bool) -> None:
|
||||
missing = conn.execute(text("SELECT id FROM compute_nodes WHERE node_index IS NULL ORDER BY id")).all()
|
||||
start = conn.execute(text("SELECT COALESCE(MAX(node_index), 0) FROM compute_nodes")).scalar() or 0
|
||||
for offset, (row_id,) in enumerate(missing, start=1):
|
||||
conn.execute(
|
||||
text("UPDATE compute_nodes SET node_index = :index WHERE id = :id"),
|
||||
{"index": int(start) + offset, "id": row_id},
|
||||
)
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_compute_nodes_node_index ON compute_nodes (node_index)"))
|
||||
if has_recovery:
|
||||
conn.execute(text("UPDATE compute_nodes SET recovery_days = 30 WHERE node_index BETWEEN 1 AND 20"))
|
||||
|
||||
|
||||
def _has_username_index(inspector) -> bool:
|
||||
|
||||
+75
-1
@@ -8,7 +8,7 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
|
||||
|
||||
from app.models import NodeStatus, OrderStatus, TaskStatus, UserRole, WithdrawalStatus, YieldStatus
|
||||
from app.models import DatacenterStatus, NodeStatus, OrderStatus, TaskStatus, UserRole, 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:_-]+$")]
|
||||
@@ -73,6 +73,9 @@ class NodeOut(APIModel):
|
||||
id: int
|
||||
user_id: int
|
||||
sn: str
|
||||
node_index: int = 0
|
||||
display_node_id: int = 0
|
||||
genesis: bool = False
|
||||
purchase_amount: MoneyText
|
||||
license_months: int
|
||||
licensed_at: datetime
|
||||
@@ -80,6 +83,18 @@ class NodeOut(APIModel):
|
||||
status: NodeStatus
|
||||
last_heartbeat_at: datetime | None
|
||||
callback_url: str | None
|
||||
gpu_model: str | None = None
|
||||
vram_gb: float | None = None
|
||||
cpu_count: int | None = None
|
||||
memory_gb: float | None = None
|
||||
hardware_weight: float = 0.05
|
||||
gpu_generation: str = "10"
|
||||
recovery_days: int = 540
|
||||
generation_locked: bool = False
|
||||
recoverable_stake_amount: MoneyText | None = None
|
||||
is_whitelist: bool = False
|
||||
client_ip: str | None = None
|
||||
hardware_fingerprint: str | None = None
|
||||
|
||||
|
||||
class NodeView(NodeOut):
|
||||
@@ -93,13 +108,23 @@ class NodeView(NodeOut):
|
||||
class HeartbeatIn(APIModel):
|
||||
sn: SN
|
||||
token: str | None = Field(default=None, max_length=128)
|
||||
gpu_model: str | None = Field(default=None, max_length=128)
|
||||
vram_gb: float | None = Field(default=None, ge=0, le=1024)
|
||||
cpu_count: int | None = Field(default=None, ge=1, le=1024)
|
||||
memory_gb: float | None = Field(default=None, ge=0, le=4096)
|
||||
hardware_fingerprint: str | None = Field(default=None, max_length=64, pattern=r"^[a-fA-F0-9]{64}$")
|
||||
|
||||
|
||||
class HeartbeatOut(APIModel):
|
||||
sn: str
|
||||
display_node_id: int = 0
|
||||
status: NodeStatus
|
||||
last_heartbeat_at: datetime
|
||||
server_time: datetime
|
||||
hardware_weight: float = 0.05
|
||||
gpu_generation: str = "10"
|
||||
recovery_days: int = 540
|
||||
recoverable_stake_amount: MoneyText | None = None
|
||||
|
||||
|
||||
class TaskOut(APIModel):
|
||||
@@ -198,6 +223,7 @@ class OrderOut(APIModel):
|
||||
created_at: datetime
|
||||
reviewed_at: datetime | None = None
|
||||
sn: str | None = None
|
||||
display_node_id: int | None = None
|
||||
|
||||
|
||||
class OrderView(OrderOut):
|
||||
@@ -253,6 +279,54 @@ class DashboardOut(APIModel):
|
||||
today_points: MoneyText
|
||||
license_price: MoneyText
|
||||
license_asset: str
|
||||
network_nodes: int = 0
|
||||
network_online: int = 0
|
||||
nodes: list[NodeView]
|
||||
orders: list[OrderOut]
|
||||
withdrawals: list[WithdrawalOut]
|
||||
datacenter_applications: list[DatacenterOut] = []
|
||||
|
||||
|
||||
class WhitelistToggle(APIModel):
|
||||
is_whitelist: bool
|
||||
|
||||
|
||||
class IPWhitelistCreate(APIModel):
|
||||
ip_address: str = Field(min_length=3, max_length=64)
|
||||
note: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class IPWhitelistOut(APIModel):
|
||||
id: int
|
||||
ip_address: str
|
||||
note: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DatacenterApply(APIModel):
|
||||
contact: str = Field(min_length=4, max_length=128)
|
||||
node_count: int = Field(ge=1, le=10000)
|
||||
subnet_ip: str = Field(min_length=3, max_length=64)
|
||||
hardware_proof: str = Field(min_length=4, max_length=2000)
|
||||
gpu_generation: str = Field(pattern=r"^(10|30|40)$")
|
||||
|
||||
|
||||
class DatacenterOut(APIModel):
|
||||
id: int
|
||||
user_id: int
|
||||
contact: str
|
||||
node_count: int
|
||||
subnet_ip: str
|
||||
hardware_proof: str
|
||||
gpu_generation: str
|
||||
status: DatacenterStatus
|
||||
note: str | None = None
|
||||
created_at: datetime
|
||||
reviewed_at: datetime | None = None
|
||||
|
||||
|
||||
class DatacenterView(DatacenterOut):
|
||||
wallet_address: str
|
||||
|
||||
|
||||
DashboardOut.model_rebuild()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""机房大客户申请,以及网段内禁止混入 10 系显卡、硬件指纹去重。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import case, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.clock import utcnow
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, DatacenterApplication, DatacenterStatus
|
||||
from app.services.hardware import GENERATION_10, GENERATION_30, GENERATION_40, classify_generation
|
||||
from app.services.ip_guard import DATACENTER_BATCH_MESSAGE, add_whitelist_ip, matching_whitelist, normalize_admin_ip
|
||||
|
||||
MIN_DATACENTER_NODES = 5
|
||||
FINGERPRINT_CLASH = "硬件指纹重复,疑似虚拟机克隆或刷量"
|
||||
_FINGERPRINT = re.compile(r"^[a-f0-9]{64}$")
|
||||
_LOW_END = re.compile(r"10\s*系|20\s*系|GTX|核显|1050|1060|1070|1080|1650|1660|2060|2070|2080", re.IGNORECASE)
|
||||
|
||||
|
||||
def assert_fingerprint(session: Session, fingerprint: str | None, node_id: int | None = None) -> str | None:
|
||||
"""同一块物理机不能挂第二台节点。空指纹不占唯一索引。"""
|
||||
text = (fingerprint or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if _FINGERPRINT.fullmatch(text) is None:
|
||||
raise AppError("硬件指纹格式不正确")
|
||||
query = select(ComputeNode.id).where(ComputeNode.hardware_fingerprint == text)
|
||||
if node_id is not None:
|
||||
query = query.where(ComputeNode.id != node_id)
|
||||
if session.scalar(query) is not None:
|
||||
raise AppError(FINGERPRINT_CLASH, 409)
|
||||
return text
|
||||
|
||||
|
||||
def assert_subnet_gpu(session: Session, ip: str, gpu_model: str | None) -> None:
|
||||
"""已经批准的机房网段里,不允许再挂 10 系及更低的卡。"""
|
||||
address = (ip or "").strip()
|
||||
if not address or "/" in address:
|
||||
return
|
||||
entry = matching_whitelist(session, address)
|
||||
if entry is None or "/" not in entry:
|
||||
return
|
||||
model = (gpu_model or "").strip()
|
||||
if not model:
|
||||
return
|
||||
generation, _days = classify_generation(model)
|
||||
if generation == GENERATION_10:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 409)
|
||||
|
||||
|
||||
def submit_application(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
contact: str,
|
||||
node_count: int,
|
||||
subnet_ip: str,
|
||||
hardware_proof: str,
|
||||
gpu_generation: str,
|
||||
) -> DatacenterApplication:
|
||||
subnet = _require_subnet(subnet_ip)
|
||||
_require_threshold(node_count, gpu_generation, hardware_proof)
|
||||
existing = session.scalar(
|
||||
select(DatacenterApplication.id).where(
|
||||
DatacenterApplication.subnet_ip == subnet,
|
||||
DatacenterApplication.status.in_((DatacenterStatus.PENDING, DatacenterStatus.APPROVED)),
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise AppError("这个网段已有待审核或已通过的机房申请", 409)
|
||||
row = DatacenterApplication(
|
||||
user_id=user_id,
|
||||
contact=contact.strip(),
|
||||
node_count=node_count,
|
||||
subnet_ip=subnet,
|
||||
hardware_proof=hardware_proof.strip(),
|
||||
gpu_generation=gpu_generation,
|
||||
status=DatacenterStatus.PENDING,
|
||||
created_at=utcnow(),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def list_applications(session: Session, user_id: int | None = None) -> list[DatacenterApplication]:
|
||||
pending_first = case((DatacenterApplication.status == DatacenterStatus.PENDING, 0), else_=1)
|
||||
query = select(DatacenterApplication).options(selectinload(DatacenterApplication.user))
|
||||
if user_id is not None:
|
||||
query = query.where(DatacenterApplication.user_id == user_id)
|
||||
return list(session.scalars(query.order_by(pending_first, DatacenterApplication.id.desc())).all())
|
||||
|
||||
|
||||
def approve_application(session: Session, application_id: int) -> DatacenterApplication:
|
||||
row = _pending(session, application_id)
|
||||
_require_threshold(row.node_count, row.gpu_generation, row.hardware_proof)
|
||||
try:
|
||||
add_whitelist_ip(session, row.subnet_ip, note=f"机房申请 #{row.id} {row.contact}")
|
||||
except AppError as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
row.status = DatacenterStatus.APPROVED
|
||||
row.reviewed_at = utcnow()
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def reject_application(session: Session, application_id: int, note: str | None = None) -> DatacenterApplication:
|
||||
row = _pending(session, application_id)
|
||||
row.status = DatacenterStatus.REJECTED
|
||||
row.note = (note or "").strip() or None
|
||||
row.reviewed_at = utcnow()
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _pending(session: Session, application_id: int) -> DatacenterApplication:
|
||||
row = session.scalar(
|
||||
select(DatacenterApplication)
|
||||
.options(selectinload(DatacenterApplication.user))
|
||||
.where(DatacenterApplication.id == application_id)
|
||||
)
|
||||
if row is None:
|
||||
raise AppError("机房申请不存在", 404)
|
||||
if row.status != DatacenterStatus.PENDING:
|
||||
raise AppError("只有待审核的机房申请可以处理", 409)
|
||||
return row
|
||||
|
||||
|
||||
def _require_subnet(value: str) -> str:
|
||||
text = value.strip()
|
||||
if "/" not in text:
|
||||
raise AppError("请填写机房出口网段,例如 203.0.113.0/24")
|
||||
return normalize_admin_ip(text)
|
||||
|
||||
|
||||
def _require_threshold(node_count: int, gpu_generation: str, hardware_proof: str) -> None:
|
||||
generation = (gpu_generation or "").strip()
|
||||
if node_count < MIN_DATACENTER_NODES or generation not in {GENERATION_30, GENERATION_40}:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 400)
|
||||
if _LOW_END.search(hardware_proof or ""):
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 400)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""根据心跳上报的显卡型号和显存,给出节点算力权重。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
HIGH = Decimal("1")
|
||||
MID = Decimal("0.5")
|
||||
LOW = Decimal("0.05")
|
||||
NONE = Decimal("0.02")
|
||||
DEFAULT_WEIGHT = LOW
|
||||
GENERATION_40 = "40"
|
||||
GENERATION_30 = "30"
|
||||
GENERATION_10 = "10"
|
||||
RECOVERY_40_DAYS = 210
|
||||
RECOVERY_30_DAYS = 365
|
||||
RECOVERY_10_DAYS = 540
|
||||
NODE_ID_BASE = 1000
|
||||
GENESIS_COUNT = 20
|
||||
GENESIS_RECOVERY_DAYS = 30
|
||||
|
||||
_MODEL_NUMBER = re.compile(r"(RTX|GTX|GT)\s*(\d{3,4})")
|
||||
_DATACENTER = ("H100", "H200", "A100", "A800", "A6000", "A40", "A30", "L40")
|
||||
_INTEGRATED = ("INTEL", "UHD", "IRIS", "BASIC DISPLAY", "MICROSOFT")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HardwareReport:
|
||||
gpu_model: str | None
|
||||
vram_gb: float | None
|
||||
cpu_count: int | None
|
||||
memory_gb: float | None
|
||||
|
||||
|
||||
def display_node_id(node_index: int) -> int:
|
||||
"""第 1 台显示 1001,第 2 台显示 1002。"""
|
||||
return NODE_ID_BASE + node_index
|
||||
|
||||
|
||||
def is_genesis(node_index: int) -> bool:
|
||||
return 1 <= node_index <= GENESIS_COUNT
|
||||
|
||||
|
||||
def displayed_population(actual: int) -> int:
|
||||
"""全网台数展示时加上 1000 的基数。3 台在线显示为 1003。"""
|
||||
return NODE_ID_BASE + actual
|
||||
|
||||
|
||||
def score_hardware(gpu_model: str | None, vram_gb: float | None) -> Decimal:
|
||||
"""高端主流卡 1.0,中端 0.5,旧卡或低端独显 0.05,没有独显 0.02。"""
|
||||
name = (gpu_model or "").strip()
|
||||
upper = name.upper()
|
||||
if not name or _is_integrated(upper):
|
||||
return NONE
|
||||
if any(token in upper for token in _DATACENTER):
|
||||
return HIGH
|
||||
match = _MODEL_NUMBER.search(upper)
|
||||
if match is not None:
|
||||
return _from_model(match.group(1), int(match.group(2)))
|
||||
if vram_gb is not None and vram_gb >= 8:
|
||||
return HIGH
|
||||
if vram_gb is not None and vram_gb >= 4:
|
||||
return MID
|
||||
return LOW
|
||||
|
||||
|
||||
def classify_generation(gpu_model: str | None) -> tuple[str, int]:
|
||||
"""40 系及以上 210 天,30 系 365 天,其余包括 10 系和核显 540 天。"""
|
||||
upper = (gpu_model or "").strip().upper()
|
||||
if not upper or _is_integrated(upper):
|
||||
return GENERATION_10, RECOVERY_10_DAYS
|
||||
if any(token in upper for token in _DATACENTER):
|
||||
return GENERATION_40, RECOVERY_40_DAYS
|
||||
match = _MODEL_NUMBER.search(upper)
|
||||
if match is not None and match.group(1) == "RTX":
|
||||
number = int(match.group(2))
|
||||
if number >= 4000:
|
||||
return GENERATION_40, RECOVERY_40_DAYS
|
||||
if number >= 3000:
|
||||
return GENERATION_30, RECOVERY_30_DAYS
|
||||
return GENERATION_10, RECOVERY_10_DAYS
|
||||
|
||||
|
||||
def _is_integrated(upper: str) -> bool:
|
||||
if any(token in upper for token in ("NVIDIA", "GEFORCE", "QUADRO")):
|
||||
return False
|
||||
if any(token in upper for token in _INTEGRATED):
|
||||
return True
|
||||
return "RADEON" in upper and "RX" not in upper
|
||||
|
||||
|
||||
def _from_model(family: str, number: int) -> Decimal:
|
||||
if family == "RTX":
|
||||
if number >= 3060:
|
||||
return HIGH
|
||||
return MID
|
||||
if family == "GTX":
|
||||
if number >= 1060:
|
||||
return MID
|
||||
return LOW
|
||||
return LOW
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
from app.services.datacenter import assert_fingerprint, assert_subnet_gpu
|
||||
from app.services.ip_guard import assert_ip_available
|
||||
from app.services.hardware import GENESIS_RECOVERY_DAYS, HardwareReport, classify_generation, is_genesis, score_hardware
|
||||
|
||||
|
||||
def assert_node_token(node: ComputeNode, token: str | None) -> None:
|
||||
@@ -24,14 +27,33 @@ def assert_node_token(node: ComputeNode, token: str | None) -> None:
|
||||
raise AppError("节点 Token 不正确", 403)
|
||||
|
||||
|
||||
def record_heartbeat(session: Session, sn: str, token: str | None = None, now: datetime | None = None) -> ComputeNode:
|
||||
def record_heartbeat(
|
||||
session: Session,
|
||||
sn: str,
|
||||
token: str | None = None,
|
||||
now: datetime | None = None,
|
||||
hardware: HardwareReport | None = None,
|
||||
client_ip: str | None = None,
|
||||
hardware_fingerprint: str | None = None,
|
||||
) -> ComputeNode:
|
||||
"""刷新最后活跃时间。离线节点收到心跳后重新打开一段在线区间。"""
|
||||
moment = now or utcnow()
|
||||
node = get_node_by_sn(session, sn.strip())
|
||||
assert_node_token(node, token)
|
||||
if node.license_expires_at <= moment:
|
||||
raise AppError("节点授权已到期", 403)
|
||||
bound_ip = (client_ip or "").strip() or None
|
||||
if bound_ip:
|
||||
assert_ip_available(session, bound_ip, node=node, now=moment)
|
||||
reported_gpu = hardware.gpu_model if hardware is not None and hardware.gpu_model else node.gpu_model
|
||||
assert_subnet_gpu(session, bound_ip, reported_gpu)
|
||||
node.client_ip = bound_ip
|
||||
fingerprint = assert_fingerprint(session, hardware_fingerprint, node.id)
|
||||
if fingerprint:
|
||||
node.hardware_fingerprint = fingerprint
|
||||
|
||||
if hardware is not None:
|
||||
_apply_hardware(node, hardware)
|
||||
node.last_heartbeat_at = moment
|
||||
node.updated_at = moment
|
||||
if node.status != NodeStatus.ONLINE:
|
||||
@@ -66,6 +88,26 @@ def mark_stale_nodes(session: Session, now: datetime | None = None) -> list[str]
|
||||
return changed
|
||||
|
||||
|
||||
def _apply_hardware(node: ComputeNode, hardware: HardwareReport) -> None:
|
||||
node.gpu_model = (hardware.gpu_model or "").strip()[:128] or None
|
||||
node.vram_gb = hardware.vram_gb
|
||||
node.cpu_count = hardware.cpu_count
|
||||
node.memory_gb = hardware.memory_gb
|
||||
node.hardware_weight = float(score_hardware(node.gpu_model, node.vram_gb))
|
||||
if node.generation_locked or not node.gpu_model:
|
||||
return
|
||||
generation, recovery_days = classify_generation(node.gpu_model)
|
||||
node.gpu_generation = generation
|
||||
node.generation_locked = True
|
||||
if is_genesis(node.node_index):
|
||||
node.recovery_days = GENESIS_RECOVERY_DAYS
|
||||
return
|
||||
node.recovery_days = recovery_days
|
||||
horizon = node.licensed_at + timedelta(days=recovery_days)
|
||||
if horizon > node.license_expires_at:
|
||||
node.license_expires_at = horizon
|
||||
|
||||
|
||||
def _ensure_open_interval(session: Session, node_id: int, started_at: datetime) -> None:
|
||||
opened = session.scalar(
|
||||
select(UptimeInterval).where(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""单 IP 只允许挂载一台普通节点。白名单单 IP、白名单网段和白名单节点跳过这条限制。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.clock import utcnow
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, IPWhitelist
|
||||
|
||||
IP_LIMIT_MESSAGE = "该 IP 已绑定过设备,非白名单 IP 仅限挂载一台"
|
||||
DATACENTER_BATCH_MESSAGE = "机房/批量接入需满足最低 5 台且全系 30/40 显卡门槛,请前往申请机房白名单"
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""优先取反向代理写下的真实地址,否则用连接对端。"""
|
||||
real = (request.headers.get("x-real-ip") or "").split(",")[0].strip()
|
||||
if real:
|
||||
return _loose_ip(real)
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
|
||||
if forwarded:
|
||||
return _loose_ip(forwarded)
|
||||
host = request.client.host if request.client is not None else ""
|
||||
return _loose_ip(host)
|
||||
|
||||
|
||||
def normalize_admin_ip(value: str) -> str:
|
||||
"""单 IP 保持地址本身,网段规范成网络地址,例如 192.168.1.10/24 变成 192.168.1.0/24。"""
|
||||
text = value.strip()
|
||||
if text.lower().startswith("::ffff:"):
|
||||
text = text[7:]
|
||||
if "/" in text:
|
||||
try:
|
||||
return str(ipaddress.ip_network(text, strict=False))
|
||||
except ValueError as exc:
|
||||
raise AppError("IP 或网段格式不正确") from exc
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError as exc:
|
||||
raise AppError("IP 或网段格式不正确") from exc
|
||||
|
||||
|
||||
def assert_ip_available(session: Session, ip: str, node: ComputeNode | None = None, now: datetime | None = None) -> None:
|
||||
"""白名单 IP,或节点已标记 is_whitelist 时,不限制同一地址上的节点数量。"""
|
||||
address = _loose_ip(ip)
|
||||
if not address:
|
||||
return
|
||||
if node is not None and node.is_whitelist:
|
||||
return
|
||||
if matching_whitelist(session, address) is not None:
|
||||
return
|
||||
moment = now or utcnow()
|
||||
query = select(ComputeNode.id).where(
|
||||
ComputeNode.client_ip == address,
|
||||
ComputeNode.is_whitelist.is_(False),
|
||||
ComputeNode.license_expires_at > moment,
|
||||
)
|
||||
if node is not None:
|
||||
query = query.where(ComputeNode.id != node.id)
|
||||
if session.scalar(query) is not None:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 409)
|
||||
|
||||
|
||||
def list_whitelist(session: Session) -> list[IPWhitelist]:
|
||||
return list(session.scalars(select(IPWhitelist).order_by(IPWhitelist.id.desc())).all())
|
||||
|
||||
|
||||
def add_whitelist_ip(session: Session, ip: str, note: str | None = None) -> IPWhitelist:
|
||||
address = normalize_admin_ip(ip)
|
||||
if _ip_listed(session, address):
|
||||
raise AppError("这个 IP 已在白名单中", 409)
|
||||
row = IPWhitelist(ip_address=address, note=(note or "").strip() or None, created_at=utcnow())
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def remove_whitelist_ip(session: Session, entry_id: int) -> IPWhitelist:
|
||||
row = session.get(IPWhitelist, entry_id)
|
||||
if row is None:
|
||||
raise AppError("白名单 IP 不存在", 404)
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def matching_whitelist(session: Session, address: str) -> str | None:
|
||||
"""请求 IP 落在某条单 IP 或网段里时,返回那条白名单原文。"""
|
||||
entries = list(session.scalars(select(IPWhitelist.ip_address)).all())
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return address if address in entries else None
|
||||
subnet_hit = None
|
||||
exact_hit = None
|
||||
for entry in entries:
|
||||
if "/" in entry:
|
||||
try:
|
||||
if ip in ipaddress.ip_network(entry, strict=False):
|
||||
subnet_hit = entry
|
||||
except ValueError:
|
||||
continue
|
||||
elif entry == str(ip):
|
||||
exact_hit = entry
|
||||
return subnet_hit or exact_hit
|
||||
|
||||
|
||||
def _ip_listed(session: Session, address: str) -> bool:
|
||||
return session.scalar(select(IPWhitelist.id).where(IPWhitelist.ip_address == address)) is not None
|
||||
|
||||
|
||||
def _loose_ip(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if text.lower().startswith("::ffff:"):
|
||||
text = text[7:]
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError:
|
||||
return text[:64]
|
||||
+160
-6
@@ -72,23 +72,172 @@ def list_orders(session: Session, user_id: int | None = None) -> list[LicenseOrd
|
||||
return list(session.scalars(query.order_by(pending_first, LicenseOrder.id.desc())).all())
|
||||
|
||||
|
||||
def render_client(sn: str, token: str, base_url: str) -> str:
|
||||
config = json.dumps({"sn": sn, "token": token, "base": base_url.rstrip("/")}, ensure_ascii=True)
|
||||
return f"""# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
|
||||
_CLIENT = """# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
CONFIG = json.loads({config!r})
|
||||
CONFIG = json.loads(__CONFIG__)
|
||||
|
||||
|
||||
def hardware():
|
||||
gpu_model, vram_gb, gpu_uuid = _gpu()
|
||||
return {
|
||||
"sn": CONFIG["sn"],
|
||||
"token": CONFIG["token"],
|
||||
"gpu_model": gpu_model,
|
||||
"vram_gb": vram_gb,
|
||||
"cpu_count": os.cpu_count(),
|
||||
"memory_gb": _memory_gb(),
|
||||
"hardware_fingerprint": _fingerprint(gpu_uuid),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(gpu_uuid):
|
||||
board = _board_serial() or ""
|
||||
mac = f"{uuid.getnode():012x}"
|
||||
material = "|".join([gpu_uuid or "", board, mac])
|
||||
if not material.strip("|"):
|
||||
return None
|
||||
return hashlib.sha256(material.encode()).hexdigest()
|
||||
|
||||
|
||||
def _board_serial():
|
||||
if os.name == "nt":
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["wmic", "baseboard", "get", "serialnumber"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=8,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
lines = [line.strip() for line in output.splitlines() if line.strip() and "SerialNumber" not in line]
|
||||
return lines[0] if lines else None
|
||||
for path in ("/sys/class/dmi/id/board_serial", "/sys/class/dmi/id/product_uuid"):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
value = handle.read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value and value.lower() not in {"none", "to be filled by o.e.m."}:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _gpu():
|
||||
probed = _pynvml()
|
||||
if probed[0]:
|
||||
return probed
|
||||
return _nvidia_smi()
|
||||
|
||||
|
||||
def _pynvml():
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
return None, None, None
|
||||
try:
|
||||
pynvml.nvmlInit()
|
||||
best_name = None
|
||||
best_bytes = -1
|
||||
best_uuid = None
|
||||
for index in range(pynvml.nvmlDeviceGetCount()):
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
|
||||
name = pynvml.nvmlDeviceGetName(handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
total = int(pynvml.nvmlDeviceGetMemoryInfo(handle).total)
|
||||
if total > best_bytes:
|
||||
uid = pynvml.nvmlDeviceGetUUID(handle)
|
||||
if isinstance(uid, bytes):
|
||||
uid = uid.decode()
|
||||
best_name = str(name)
|
||||
best_bytes = total
|
||||
best_uuid = str(uid)
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
return None, None, None
|
||||
if not best_name or best_bytes < 0:
|
||||
return None, None, None
|
||||
return best_name, round(best_bytes / (1024 ** 3), 2), best_uuid
|
||||
|
||||
|
||||
def _nvidia_smi():
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=name,memory.total,uuid", "--format=csv,noheader,nounits"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=8,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None, None, None
|
||||
best_name = None
|
||||
best_mib = -1.0
|
||||
best_uuid = None
|
||||
for line in output.splitlines():
|
||||
parts = [part.strip() for part in line.split(",", 2)]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name, memory = parts[0], parts[1]
|
||||
gpu_uuid = parts[2] if len(parts) > 2 else None
|
||||
try:
|
||||
mib = float(memory)
|
||||
except ValueError:
|
||||
continue
|
||||
if mib > best_mib:
|
||||
best_name = name or None
|
||||
best_mib = mib
|
||||
best_uuid = gpu_uuid or None
|
||||
if best_name is None:
|
||||
return None, None, None
|
||||
return best_name, round(best_mib / 1024, 2), best_uuid
|
||||
|
||||
|
||||
def _memory_gb():
|
||||
if os.name == "nt":
|
||||
class MemoryStatus(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
status = MemoryStatus()
|
||||
status.dwLength = ctypes.sizeof(MemoryStatus)
|
||||
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
||||
return round(status.ullTotalPhys / (1024 ** 3), 2)
|
||||
return None
|
||||
try:
|
||||
with open("/proc/meminfo", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("MemTotal:"):
|
||||
return round(int(line.split()[1]) / 1024 / 1024, 2)
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def beat():
|
||||
body = json.dumps({{"sn": CONFIG["sn"], "token": CONFIG["token"]}}).encode()
|
||||
body = json.dumps(hardware()).encode()
|
||||
request = urllib.request.Request(
|
||||
CONFIG["base"] + "/api/v1/node/heartbeat",
|
||||
data=body,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
print(response.read().decode())
|
||||
@@ -104,6 +253,11 @@ if __name__ == "__main__":
|
||||
"""
|
||||
|
||||
|
||||
def render_client(sn: str, token: str, base_url: str) -> str:
|
||||
config = json.dumps({"sn": sn, "token": token, "base": base_url.rstrip("/")}, ensure_ascii=True)
|
||||
return _CLIENT.replace("__CONFIG__", repr(config))
|
||||
|
||||
|
||||
def _pending_order(session: Session, order_id: int) -> LicenseOrder:
|
||||
order = session.scalar(
|
||||
select(LicenseOrder)
|
||||
|
||||
+21
-4
@@ -10,8 +10,9 @@ from sqlalchemy.orm import Session, selectinload
|
||||
from app.clock import utcnow
|
||||
from app.models import ComputeNode, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
|
||||
from app.money import money, to_decimal
|
||||
from app.services.hardware import display_node_id, displayed_population, is_genesis
|
||||
from app.services.withdrawals import available_balance
|
||||
from app.services.yield_engine import compute_node_yield
|
||||
from app.services.yield_engine import network_yields
|
||||
|
||||
|
||||
def settled_points(session: Session, user_id: int | None = None) -> Decimal:
|
||||
@@ -47,13 +48,17 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||
if user_id is not None:
|
||||
query = query.where(ComputeNode.user_id == user_id)
|
||||
today = utcnow().date()
|
||||
scored = {node.id: computed for node, computed in network_yields(session, today)}
|
||||
snapshots = []
|
||||
for node in session.scalars(query).all():
|
||||
computed = compute_node_yield(session, node, today)
|
||||
computed = scored.get(node.id)
|
||||
snapshots.append(
|
||||
{
|
||||
"id": node.id,
|
||||
"sn": node.sn,
|
||||
"node_index": node.node_index,
|
||||
"display_node_id": display_node_id(node.node_index),
|
||||
"genesis": is_genesis(node.node_index),
|
||||
"user_id": node.user_id,
|
||||
"wallet_address": node.user.wallet_address,
|
||||
"purchase_amount": node.purchase_amount,
|
||||
@@ -65,8 +70,20 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||
"callback_url": node.callback_url,
|
||||
"node_token": node.node_token if user_id is not None else None,
|
||||
"activated": node.node_token is not None,
|
||||
"online_rate": computed.online_rate,
|
||||
"today_points": computed.points_delta if computed.eligible else Decimal("0"),
|
||||
"online_rate": computed.online_rate if computed is not None else Decimal("0"),
|
||||
"today_points": computed.points_delta if computed is not None and computed.eligible else Decimal("0"),
|
||||
"gpu_model": node.gpu_model,
|
||||
"vram_gb": node.vram_gb,
|
||||
"cpu_count": node.cpu_count,
|
||||
"memory_gb": node.memory_gb,
|
||||
"hardware_weight": node.hardware_weight if node.hardware_weight is not None else 0.05,
|
||||
"gpu_generation": node.gpu_generation or "10",
|
||||
"recovery_days": node.recovery_days or 540,
|
||||
"generation_locked": bool(node.generation_locked),
|
||||
"recoverable_stake_amount": node.recoverable_stake_amount,
|
||||
"is_whitelist": bool(node.is_whitelist),
|
||||
"client_ip": node.client_ip,
|
||||
"hardware_fingerprint": node.hardware_fingerprint,
|
||||
}
|
||||
)
|
||||
return snapshots
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -15,6 +15,14 @@ from app.errors import AppError
|
||||
from app.models import ComputeNode, NodeStatus, User, UserRole
|
||||
from app.money import money
|
||||
from app.services.common import get_user_by_wallet
|
||||
from app.services.ip_guard import assert_ip_available
|
||||
from app.services.hardware import (
|
||||
DEFAULT_WEIGHT,
|
||||
GENERATION_10,
|
||||
GENESIS_RECOVERY_DAYS,
|
||||
RECOVERY_10_DAYS,
|
||||
is_genesis,
|
||||
)
|
||||
|
||||
|
||||
def register_user(
|
||||
@@ -52,16 +60,23 @@ def register_node(
|
||||
purchase_amount: Decimal,
|
||||
callback_url: str | None = None,
|
||||
node_token: str | None = None,
|
||||
client_ip: 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)
|
||||
bound_ip = (client_ip or "").strip() or None
|
||||
if bound_ip:
|
||||
assert_ip_available(session, bound_ip)
|
||||
callback = _normalize_callback(callback_url)
|
||||
started = utcnow()
|
||||
node_index = int(session.scalar(select(func.max(ComputeNode.node_index))) or 0) + 1
|
||||
recovery_days = GENESIS_RECOVERY_DAYS if is_genesis(node_index) else RECOVERY_10_DAYS
|
||||
node = ComputeNode(
|
||||
user_id=user.id,
|
||||
sn=serial,
|
||||
node_index=node_index,
|
||||
purchase_amount=money(purchase_amount),
|
||||
license_months=settings.license_months,
|
||||
licensed_at=started,
|
||||
@@ -69,6 +84,13 @@ def register_node(
|
||||
status=NodeStatus.OFFLINE,
|
||||
callback_url=callback,
|
||||
node_token=node_token,
|
||||
hardware_weight=float(DEFAULT_WEIGHT),
|
||||
gpu_generation=GENERATION_10,
|
||||
recovery_days=recovery_days,
|
||||
generation_locked=False,
|
||||
recoverable_stake_amount=money(purchase_amount),
|
||||
is_whitelist=False,
|
||||
client_ip=bound_ip,
|
||||
created_at=started,
|
||||
updated_at=started,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
3. 任务响应率 = 校验通过且执行成功的任务数 / 当日内下发任务数。
|
||||
4. 当天没有下发任务时,表现系数等于在线率,避免调度器故障误伤节点。
|
||||
有任务时,表现系数 = 在线权重 × 在线率 + 任务权重 × 响应率。
|
||||
5. 算力产出 = 授权金额 × 表现系数。
|
||||
当日积分 = 算力产出 / 授权天数。
|
||||
6. 单节点累计积分不超过授权金额。已存在的当日流水不会被覆盖。
|
||||
5. 显卡世代决定回本天数:40 系及以上 210 天,30 系 365 天,10 系及以下或核显 540 天。
|
||||
每日基础释放 = 购买金额 / 回本天数。
|
||||
当日积分 = 每日基础释放 × 表现系数。表现系数就是当日平台收益加权系数。
|
||||
6. 积分优先冲减 recoverable_stake_amount,直到质押金返还完毕。已存在的当日流水不会被覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,6 +29,7 @@ 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.hardware import DEFAULT_WEIGHT, RECOVERY_10_DAYS
|
||||
from app.services.tasks import expire_overdue_tasks
|
||||
|
||||
|
||||
@@ -47,6 +49,9 @@ class YieldComputation:
|
||||
compute_output: Decimal = Decimal("0")
|
||||
points_delta: Decimal = Decimal("0")
|
||||
cycle_days: int = 0
|
||||
base_release: Decimal = Decimal("0")
|
||||
hardware_weight: Decimal = Decimal("0")
|
||||
remaining_points: Decimal = Decimal("0")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -68,9 +73,9 @@ def compute_node_yield(
|
||||
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))
|
||||
recovery_days = int(node.recovery_days or RECOVERY_10_DAYS)
|
||||
if window_end <= window_start or recovery_days <= 0:
|
||||
return YieldComputation(eligible=False, yield_date=yield_date, cycle_days=max(recovery_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)
|
||||
@@ -94,9 +99,11 @@ def compute_node_yield(
|
||||
applied_online = to_decimal(settings.online_weight)
|
||||
applied_task = to_decimal(settings.task_weight)
|
||||
performance = rate(applied_online * online_rate + applied_task * response_rate)
|
||||
base_release = to_decimal(node.purchase_amount) / Decimal(recovery_days)
|
||||
hardware_weight = to_decimal(node.hardware_weight if node.hardware_weight is not None else DEFAULT_WEIGHT)
|
||||
remaining = _remaining_stake(session, node)
|
||||
points_delta = min(money(base_release * performance), remaining)
|
||||
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,
|
||||
@@ -112,16 +119,20 @@ def compute_node_yield(
|
||||
performance=performance,
|
||||
compute_output=compute_output,
|
||||
points_delta=points_delta,
|
||||
cycle_days=cycle_days,
|
||||
cycle_days=recovery_days,
|
||||
base_release=base_release,
|
||||
hardware_weight=hardware_weight,
|
||||
remaining_points=remaining,
|
||||
)
|
||||
|
||||
|
||||
def settle_yield_date(session: Session, yield_date: date, now: datetime | None = None) -> SettleSummary:
|
||||
"""清算指定 UTC 日。每个节点当天已有流水时跳过,保证脚本重复执行不会重复入账。"""
|
||||
def network_yields(
|
||||
session: Session,
|
||||
yield_date: date,
|
||||
now: datetime | None = None,
|
||||
) -> list[tuple[ComputeNode, YieldComputation]]:
|
||||
"""算出授权覆盖该日的全部节点。积分按各节点自己的显卡世代释放。"""
|
||||
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)
|
||||
@@ -131,23 +142,32 @@ def settle_yield_date(session: Session, yield_date: date, now: datetime | None =
|
||||
)
|
||||
.order_by(ComputeNode.id)
|
||||
).all()
|
||||
return [(node, compute_node_yield(session, node, yield_date, moment)) for node in nodes]
|
||||
|
||||
|
||||
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)
|
||||
created = 0
|
||||
skipped = 0
|
||||
total = Decimal("0")
|
||||
partial = yield_date == moment.date()
|
||||
for node in nodes:
|
||||
for node, computed in network_yields(session, yield_date, moment):
|
||||
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))
|
||||
node.recoverable_stake_amount = money(computed.remaining_points - computed.points_delta)
|
||||
except IntegrityError:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -204,6 +224,15 @@ def _proven_end(interval: UptimeInterval, node: ComputeNode, now: datetime) -> d
|
||||
return now
|
||||
|
||||
|
||||
def _remaining_stake(session: Session, node: ComputeNode) -> Decimal:
|
||||
if node.recoverable_stake_amount is not None:
|
||||
remaining = to_decimal(node.recoverable_stake_amount)
|
||||
if remaining <= 0:
|
||||
return Decimal("0")
|
||||
return money(remaining)
|
||||
return _remaining_points(session, node)
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
Reference in New Issue
Block a user