按显卡世代和创世编号结算回本,机房批量接入需审核网段并用硬件指纹防克隆。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+131
-10
@@ -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,6 +344,11 @@ 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 = 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"))
|
||||
|
||||
@@ -193,7 +193,36 @@ input:focus { border-color: var(--gold); }
|
||||
.pill.online, .pill.completed { color: var(--online); }
|
||||
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
|
||||
.pill.pending { color: var(--gold); }
|
||||
.genesis-tag {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
margin: 12px 0 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(224, 192, 122, 0.55);
|
||||
border-radius: 999px;
|
||||
color: #e0c07a;
|
||||
background: #08090d;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.network-line { margin: 18px 0 0; color: #e0c07a; font-size: 14px; letter-spacing: 0.04em; }
|
||||
.whitelist-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; }
|
||||
.toggle { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; letter-spacing: 0.06em; cursor: pointer; }
|
||||
.toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.toggle span { position: relative; width: 42px; height: 24px; border-radius: 999px; border: 1px solid rgba(224, 192, 122, 0.4); background: #08090d; }
|
||||
.toggle span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #8a8478; transition: transform 0.15s ease, background 0.15s ease; }
|
||||
.toggle input:checked + span { border-color: #e0c07a; }
|
||||
.toggle input:checked + span::after { transform: translateX(18px); background: #e0c07a; }
|
||||
.toggle input:focus-visible + span { outline: 1px solid #e0c07a; }
|
||||
.whitelist-panel { margin-top: 8px; }
|
||||
.ip-form { display: grid; grid-template-columns: 1.2fr 1fr auto; gap: 12px; align-items: end; }
|
||||
.ip-form button { height: 42px; }
|
||||
@media (max-width: 720px) { .ip-form { grid-template-columns: 1fr; } }
|
||||
.forms { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; align-items: start; }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
textarea, select { width: 100%; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: rgba(8, 9, 13, 0.55); color: inherit; font: inherit; }
|
||||
textarea:focus, select:focus { border-color: var(--gold); outline: none; }
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
.panel { display: grid; gap: 12px; padding: 22px; }
|
||||
.panel h2 { font-family: var(--serif); font-size: 28px; font-weight: 560; }
|
||||
.table-wrap { overflow: auto; margin-top: 8px; }
|
||||
|
||||
@@ -10,23 +10,30 @@ const nodes = ref([]);
|
||||
const withdrawals = ref([]);
|
||||
const orders = ref([]);
|
||||
const hashes = ref({});
|
||||
const whitelist = ref([]);
|
||||
const applications = ref([]);
|
||||
const ipForm = ref({ ip_address: "", note: "" });
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
let timer = 0;
|
||||
|
||||
async function load() {
|
||||
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders] = await Promise.all([
|
||||
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders, nextIps, nextApps] = await Promise.all([
|
||||
api("/admin/summary"),
|
||||
api("/admin/users"),
|
||||
api("/admin/nodes"),
|
||||
api("/admin/withdrawals"),
|
||||
api("/admin/orders"),
|
||||
api("/admin/ip-whitelist"),
|
||||
api("/admin/datacenter-applications"),
|
||||
]);
|
||||
summary.value = nextSummary;
|
||||
users.value = nextUsers;
|
||||
nodes.value = nextNodes;
|
||||
withdrawals.value = nextCash;
|
||||
orders.value = nextOrders;
|
||||
whitelist.value = nextIps;
|
||||
applications.value = nextApps;
|
||||
}
|
||||
|
||||
async function approveOrder(row) {
|
||||
@@ -34,7 +41,7 @@ async function approveOrder(row) {
|
||||
notice.value = "";
|
||||
try {
|
||||
const result = await api(`/admin/orders/${row.id}/approve`, { method: "POST" });
|
||||
notice.value = `订单 #${row.id} 已开通,节点 ${result.sn}`;
|
||||
notice.value = `订单 #${row.id} 已开通,节点 ${result.display_node_id || result.sn}`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
@@ -76,6 +83,82 @@ async function confirm(row) {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleWhitelist(node) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
const next = !node.is_whitelist;
|
||||
try {
|
||||
await api(`/admin/nodes/${encodeURIComponent(node.sn)}/whitelist`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ is_whitelist: next }),
|
||||
});
|
||||
notice.value = next
|
||||
? `节点 ${node.display_node_id || node.sn} 已加入白名单`
|
||||
: `节点 ${node.display_node_id || node.sn} 已取消白名单`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function addIp() {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await api("/admin/ip-whitelist", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ip_address: ipForm.value.ip_address.trim(),
|
||||
note: ipForm.value.note.trim(),
|
||||
}),
|
||||
});
|
||||
ipForm.value = { ip_address: "", note: "" };
|
||||
notice.value = "白名单 IP 已添加";
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveDatacenter(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await api(`/admin/datacenter-applications/${row.id}/approve`, { method: "POST" });
|
||||
notice.value = `机房申请 #${row.id} 已通过,网段 ${row.subnet_ip} 已加入白名单`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectDatacenter(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await api(`/admin/datacenter-applications/${row.id}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ note: "未达到机房门槛" }),
|
||||
});
|
||||
notice.value = `机房申请 #${row.id} 已拒绝`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeIp(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await api(`/admin/ip-whitelist/${row.id}`, { method: "DELETE" });
|
||||
notice.value = `${row.ip_address} 已移出白名单`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function reject(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
@@ -118,7 +201,7 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<em>已注册钱包</em>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>节点</span>
|
||||
<span>全网节点</span>
|
||||
<strong>{{ summary.nodes }}</strong>
|
||||
<em>在线 {{ summary.online }} · 离线 {{ summary.offline }}</em>
|
||||
</article>
|
||||
@@ -147,7 +230,7 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
|
||||
<td class="mono">{{ row.tx_id }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td>{{ row.sn || "—" }}</td>
|
||||
<td>{{ row.display_node_id || row.sn || "—" }}</td>
|
||||
<td class="actions" v-if="row.status === 'pending'">
|
||||
<button class="gold" type="button" @click="approveOrder(row)">通过</button>
|
||||
<button class="texty" type="button" @click="rejectOrder(row)">拒绝</button>
|
||||
@@ -163,19 +246,84 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<section class="board">
|
||||
<article v-for="node in nodes" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.sn }}</strong>
|
||||
<strong>{{ node.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
|
||||
</header>
|
||||
<div class="whitelist-row">
|
||||
<span v-if="node.is_whitelist" class="pill pending">白名单</span>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" :checked="node.is_whitelist" @change="toggleWhitelist(node)" />
|
||||
<span></span>
|
||||
白名单
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<p class="wallet">{{ node.wallet_address }}</p>
|
||||
<p class="sub">{{ node.sn }}</p>
|
||||
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
|
||||
<dl>
|
||||
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
|
||||
<div><dt>心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
|
||||
<div><dt>绑定 IP</dt><dd>{{ node.client_ip || "—" }}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
<p v-if="!nodes.length" class="empty">还没有节点。</p>
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">IP 白名单设置</h2>
|
||||
<section class="panel whitelist-panel">
|
||||
<p class="sub">可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。</p>
|
||||
<form class="ip-form" @submit.prevent="addIp">
|
||||
<label>豁免 IP / 网段<input v-model="ipForm.ip_address" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>备注<input v-model="ipForm.note" placeholder="自营机房出口" /></label>
|
||||
<button class="gold" type="submit">添加</button>
|
||||
</form>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>IP</th><th>备注</th><th>添加时间</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in whitelist" :key="row.id">
|
||||
<td class="mono">{{ row.ip_address }}</td>
|
||||
<td>{{ row.note || "—" }}</td>
|
||||
<td>{{ when(row.created_at) }}</td>
|
||||
<td class="actions"><button class="texty" type="button" @click="removeIp(row)">删除</button></td>
|
||||
</tr>
|
||||
<tr v-if="!whitelist.length"><td colspan="4" class="empty">还没有豁免 IP。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">机房大客户申请</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>钱包</th><th>联系方式</th><th>节点数</th><th>网段</th><th>显卡</th><th>证明</th><th>状态</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in applications" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td class="mono">{{ row.wallet_address }}</td>
|
||||
<td>{{ row.contact }}</td>
|
||||
<td>{{ row.node_count }}</td>
|
||||
<td class="mono">{{ row.subnet_ip }}</td>
|
||||
<td>{{ row.gpu_generation }} 系</td>
|
||||
<td>{{ row.hardware_proof }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td class="actions" v-if="row.status === 'pending'">
|
||||
<button class="gold" type="button" @click="approveDatacenter(row)">通过</button>
|
||||
<button class="texty" type="button" @click="rejectDatacenter(row)">拒绝</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
<tr v-if="!applications.length"><td colspan="9" class="empty">还没有机房申请。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">提现审核</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
|
||||
@@ -9,6 +9,13 @@ const error = ref("");
|
||||
const notice = ref("");
|
||||
const txId = ref("");
|
||||
const cashForm = ref({ amount: "", asset_symbol: "POINT" });
|
||||
const datacenterForm = ref({
|
||||
contact: "",
|
||||
node_count: "5",
|
||||
subnet_ip: "",
|
||||
hardware_proof: "",
|
||||
gpu_generation: "40",
|
||||
});
|
||||
let timer = 0;
|
||||
|
||||
async function load() {
|
||||
@@ -27,6 +34,17 @@ async function run(work, done) {
|
||||
}
|
||||
}
|
||||
|
||||
function gpuText(node) {
|
||||
if (!node.gpu_model) return "未上报";
|
||||
return node.vram_gb ? `${node.gpu_model} · ${node.vram_gb} GB` : node.gpu_model;
|
||||
}
|
||||
|
||||
function generationText(node) {
|
||||
if (node.gpu_generation === "40") return "40系及以上";
|
||||
if (node.gpu_generation === "30") return "30系";
|
||||
return "10系及以下";
|
||||
}
|
||||
|
||||
function placeOrder() {
|
||||
return run(async () => {
|
||||
await api("/me/orders", { method: "POST", body: JSON.stringify({ tx_id: txId.value.trim() }) });
|
||||
@@ -48,6 +66,23 @@ function downloadClient(node) {
|
||||
return run(() => download(`/me/nodes/${encodeURIComponent(node.sn)}/client`, `hypernode-${node.sn}.py`), "客户端已开始下载");
|
||||
}
|
||||
|
||||
function applyDatacenter() {
|
||||
return run(async () => {
|
||||
await api("/me/datacenter-applications", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
contact: datacenterForm.value.contact.trim(),
|
||||
node_count: Number(datacenterForm.value.node_count),
|
||||
subnet_ip: datacenterForm.value.subnet_ip.trim(),
|
||||
hardware_proof: datacenterForm.value.hardware_proof.trim(),
|
||||
gpu_generation: datacenterForm.value.gpu_generation,
|
||||
}),
|
||||
});
|
||||
datacenterForm.value.subnet_ip = "";
|
||||
datacenterForm.value.hardware_proof = "";
|
||||
}, "机房申请已提交,等待审核");
|
||||
}
|
||||
|
||||
function requestCash() {
|
||||
return run(async () => {
|
||||
await api("/me/withdrawals", {
|
||||
@@ -93,9 +128,9 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<em>按当前在线实时估算</em>
|
||||
</article>
|
||||
<article class="metric">
|
||||
<span>节点</span>
|
||||
<strong>{{ board.nodes.length }}</strong>
|
||||
<em>{{ board.nodes.filter((node) => node.status === "online").length }} 台在线</em>
|
||||
<span>全网节点</span>
|
||||
<strong>{{ board.network_nodes }}</strong>
|
||||
<em>在线 {{ board.network_online }}</em>
|
||||
</article>
|
||||
<p class="fine">* 算力积分兑换汇率:1 积分 = 0.8 ~ 1.0 USDT(根据全网算力动态浮动,当前基准约 0.95 USDT)</p>
|
||||
</section>
|
||||
@@ -108,7 +143,7 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</header>
|
||||
<p class="wallet">{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}</p>
|
||||
<p v-if="order.status === 'pending'" class="sub">审核通过前不能复制 Token,也不能下载客户端。</p>
|
||||
<p v-else-if="order.sn" class="sub">节点 {{ order.sn }}</p>
|
||||
<p v-else-if="order.display_node_id" class="sub">节点 {{ order.display_node_id }}</p>
|
||||
<div v-if="order.status !== 'approved'" class="actions">
|
||||
<button class="gold" type="button" disabled>复制 Token</button>
|
||||
<button class="texty" type="button" disabled>下载客户端</button>
|
||||
@@ -116,14 +151,21 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</article>
|
||||
<article v-for="node in board?.nodes || []" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.sn }}</strong>
|
||||
<strong>{{ node.display_node_id || node.sn }}</strong>
|
||||
<span class="pill" :class="node.activated ? node.status : 'pending'"><i />{{ node.activated ? statusText[node.status] : "待审核" }}</span>
|
||||
</header>
|
||||
<p v-if="node.genesis" class="genesis-tag">[Genesis Special Node - 30 Days Recovery]</p>
|
||||
<p v-if="node.genesis" class="sub">创世特权节点 - 30天回本</p>
|
||||
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
|
||||
<dl>
|
||||
<div><dt>购买金额</dt><dd>{{ money(node.purchase_amount) }}</dd></div>
|
||||
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
|
||||
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
|
||||
<div><dt>显卡</dt><dd>{{ gpuText(node) }}</dd></div>
|
||||
<div><dt>硬件权重</dt><dd>{{ node.hardware_weight }}</dd></div>
|
||||
<div><dt>显卡世代</dt><dd>{{ generationText(node) }}</dd></div>
|
||||
<div><dt>回本周期</dt><dd>{{ node.recovery_days }} 天</dd></div>
|
||||
<div><dt>待返还质押</dt><dd>{{ money(node.recoverable_stake_amount || 0) }}</dd></div>
|
||||
</dl>
|
||||
<label>挂机 Token<input class="token-box" :value="node.activated ? node.node_token : '审核通过后发放'" readonly /></label>
|
||||
<div class="actions">
|
||||
@@ -154,6 +196,27 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
<form class="panel span-2" @submit.prevent="applyDatacenter">
|
||||
<h2>机房白名单申请</h2>
|
||||
<p class="sub">同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。</p>
|
||||
<label>联系方式<input v-model="datacenterForm.contact" required minlength="4" placeholder="Telegram / 邮箱 / 手机" /></label>
|
||||
<label>预估节点数<input v-model="datacenterForm.node_count" required type="number" min="5" placeholder="5" /></label>
|
||||
<label>出口网段<input v-model="datacenterForm.subnet_ip" required placeholder="203.0.113.0/24" /></label>
|
||||
<label>显卡世代
|
||||
<select v-model="datacenterForm.gpu_generation">
|
||||
<option value="40">40 系及以上</option>
|
||||
<option value="30">30 系</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>硬件配置证明<textarea v-model="datacenterForm.hardware_proof" required minlength="4" placeholder="例如 8 台 RTX 4090,附采购或机房照片说明" /></label>
|
||||
<button class="gold" type="submit">提交机房申请</button>
|
||||
<ul class="mini">
|
||||
<li v-for="row in board?.datacenter_applications || []" :key="row.id">
|
||||
<span>#{{ row.id }} · {{ row.subnet_ip }} · {{ row.node_count }} 台</span>
|
||||
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</form>
|
||||
</section>
|
||||
</Shell>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { api, saveSession } from "../api";
|
||||
|
||||
@@ -15,6 +15,18 @@ const form = ref({
|
||||
confirm: "",
|
||||
});
|
||||
const termsOpen = ref(false);
|
||||
const networkNodes = ref(null);
|
||||
const networkOnline = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api("/network");
|
||||
networkNodes.value = data.nodes;
|
||||
networkOnline.value = data.online;
|
||||
} catch {
|
||||
networkNodes.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
@@ -68,6 +80,7 @@ function register() {
|
||||
<p class="eyebrow">HyperNode</p>
|
||||
<h1>连接全球 DePIN 分布式算力网络</h1>
|
||||
<p>基于去中心化节点的推理验证与任务调度平台。连接您的算力节点,实时监控质押释放与算力收益。</p>
|
||||
<p v-if="networkNodes !== null" class="network-line">全网节点 {{ networkNodes }} · 在线 {{ networkOnline }}</p>
|
||||
</section>
|
||||
<form class="gate-card" @submit.prevent="submit">
|
||||
<div class="switch">
|
||||
|
||||
+256
-11
@@ -25,9 +25,11 @@ from app.clock import utcnow
|
||||
from app.database import Base, SessionLocal, engine
|
||||
from app.main import app
|
||||
from app.jobs.daily_yield import run_daily_yield_cron
|
||||
from app.money import money
|
||||
from app.models import (
|
||||
ComputeNode,
|
||||
ComputeTask,
|
||||
IPWhitelist,
|
||||
NodeStatus,
|
||||
NodeYield,
|
||||
PushStatus,
|
||||
@@ -36,6 +38,8 @@ from app.models import (
|
||||
YieldStatus,
|
||||
)
|
||||
from app.services.heartbeat import mark_stale_nodes
|
||||
from app.services.datacenter import FINGERPRINT_CLASH
|
||||
from app.services.ip_guard import DATACENTER_BATCH_MESSAGE
|
||||
from app.services.tasks import dispatch_random_tasks
|
||||
|
||||
|
||||
@@ -45,6 +49,9 @@ class CoreFlowTest(unittest.TestCase):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
self.client_cm = TestClient(app)
|
||||
self.client = self.client_cm.__enter__()
|
||||
with SessionLocal() as session:
|
||||
session.add(IPWhitelist(ip_address="testclient", note="unit test", created_at=utcnow()))
|
||||
session.commit()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client_cm.__exit__(None, None, None)
|
||||
@@ -147,18 +154,18 @@ class CoreFlowTest(unittest.TestCase):
|
||||
self.assertEqual(full_row.online_rate, Decimal("1.00000000"))
|
||||
self.assertEqual(full_row.task_response_rate, Decimal("1.00000000"))
|
||||
self.assertEqual(full_row.performance, Decimal("1.00000000"))
|
||||
self.assertEqual(full_row.points_delta, Decimal("1.00000000"))
|
||||
self.assertEqual(full_row.points_delta, money(Decimal(365) / Decimal(30)))
|
||||
self.assertEqual(full_row.compute_output, Decimal("365.00000000"))
|
||||
self.assertEqual(full_row.online_weight, Decimal("0.70000000"))
|
||||
|
||||
self.assertEqual(idle_row.tasks_dispatched, 0)
|
||||
self.assertEqual(idle_row.performance, Decimal("1.00000000"))
|
||||
self.assertEqual(idle_row.points_delta, Decimal("1.00000000"))
|
||||
self.assertEqual(idle_row.points_delta, money(Decimal(365) / Decimal(30)))
|
||||
self.assertEqual(idle_row.online_weight, Decimal("1.00000000"))
|
||||
|
||||
self.assertEqual(miss_row.task_response_rate, Decimal("0.00000000"))
|
||||
self.assertEqual(miss_row.performance, Decimal("0.70000000"))
|
||||
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
|
||||
self.assertEqual(miss_row.points_delta, money(Decimal(365) / Decimal(30) * Decimal("0.7")))
|
||||
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
|
||||
|
||||
again = self.client.post(
|
||||
@@ -198,7 +205,7 @@ class CoreFlowTest(unittest.TestCase):
|
||||
row = self._yield_row("NODE-HALF")
|
||||
self.assertEqual(row.online_seconds, 12 * 60 * 60)
|
||||
self.assertEqual(row.online_rate, Decimal("0.50000000"))
|
||||
self.assertEqual(row.points_delta, Decimal("0.50000000"))
|
||||
self.assertEqual(row.points_delta, money(Decimal(365) / Decimal(30) * Decimal("0.5")))
|
||||
|
||||
def test_daily_cron_writes_previous_closed_day(self) -> None:
|
||||
# 北京时间 2026-10-12 00:05,对应上一个已结束的 UTC 账本日 2026-10-10。
|
||||
@@ -213,7 +220,7 @@ class CoreFlowTest(unittest.TestCase):
|
||||
self.assertFalse(summary.is_partial)
|
||||
row = self._yield_row("NODE-CRON")
|
||||
self.assertEqual(row.status, YieldStatus.SETTLED)
|
||||
self.assertEqual(row.points_delta, Decimal("1.00000000"))
|
||||
self.assertEqual(row.points_delta, money(Decimal(365) / Decimal(30)))
|
||||
self.assertEqual(row.compute_output, Decimal("365.00000000"))
|
||||
|
||||
again = run_daily_yield_cron(now=moment)
|
||||
@@ -236,7 +243,7 @@ class CoreFlowTest(unittest.TestCase):
|
||||
too_much = self.client.post(
|
||||
"/api/v1/withdrawals",
|
||||
headers=payer,
|
||||
json={"wallet_address": "0xpay", "amount": "10"},
|
||||
json={"wallet_address": "0xpay", "amount": "100"},
|
||||
)
|
||||
self.assertEqual(too_much.status_code, 400, too_much.text)
|
||||
|
||||
@@ -247,12 +254,13 @@ class CoreFlowTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
withdrawal_id = created.json()["id"]
|
||||
self.assertEqual(created.json()["available_after"], "0.60000000")
|
||||
released = money(Decimal(365) / Decimal(30))
|
||||
self.assertEqual(created.json()["available_after"], format(money(released - Decimal("0.4")), "f"))
|
||||
|
||||
blocked = self.client.post(
|
||||
"/api/v1/withdrawals",
|
||||
headers=payer,
|
||||
json={"wallet_address": "0xpay", "amount": "0.7"},
|
||||
json={"wallet_address": "0xpay", "amount": "12"},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 400, blocked.text)
|
||||
|
||||
@@ -266,7 +274,7 @@ class CoreFlowTest(unittest.TestCase):
|
||||
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
|
||||
|
||||
balance = self.client.get("/api/v1/users/0xpay/balance", headers=self._auth("0xpay"))
|
||||
self.assertEqual(balance.json()["available_points"], "0.60000000")
|
||||
self.assertEqual(balance.json()["available_points"], format(money(released - Decimal("0.4")), "f"))
|
||||
|
||||
def test_roles_split_user_and_admin(self) -> None:
|
||||
registered = self.client.post(
|
||||
@@ -325,6 +333,10 @@ class CoreFlowTest(unittest.TestCase):
|
||||
board = self.client.get("/api/v1/me/dashboard", headers=headers)
|
||||
node = board.json()["nodes"][0]
|
||||
self.assertEqual(node["sn"], sn)
|
||||
self.assertEqual(node["display_node_id"], 1001)
|
||||
self.assertTrue(node["genesis"])
|
||||
self.assertEqual(node["recovery_days"], 30)
|
||||
self.assertEqual(board.json()["network_nodes"], 1001)
|
||||
self.assertTrue(node["activated"])
|
||||
self.assertTrue(node["node_token"])
|
||||
self.assertEqual(node["license_months"], 12)
|
||||
@@ -338,6 +350,8 @@ class CoreFlowTest(unittest.TestCase):
|
||||
client = self.client.get(f"/api/v1/me/nodes/{sn}/client", headers=headers)
|
||||
self.assertEqual(client.status_code, 200, client.text)
|
||||
self.assertIn(node["node_token"], client.text)
|
||||
self.assertIn("nvidia-smi", client.text)
|
||||
self.assertIn("pynvml", client.text)
|
||||
|
||||
duplicated = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"})
|
||||
self.assertEqual(duplicated.status_code, 409, duplicated.text)
|
||||
@@ -352,6 +366,237 @@ class CoreFlowTest(unittest.TestCase):
|
||||
self.assertEqual(rejected.json()["status"], "rejected")
|
||||
self.assertIsNone(rejected.json()["sn"])
|
||||
|
||||
def test_gpu_generation_locks_recovery_and_releases_stake(self) -> None:
|
||||
from app.services.hardware import classify_generation, score_hardware
|
||||
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce RTX 4090"), ("40", 210))
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce RTX 4070"), ("40", 210))
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce RTX 3060 Ti"), ("30", 365))
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce RTX 3060"), ("30", 365))
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce GTX 1080"), ("10", 540))
|
||||
self.assertEqual(classify_generation("NVIDIA GeForce GTX 1050"), ("10", 540))
|
||||
self.assertEqual(classify_generation("Intel UHD Graphics 630"), ("10", 540))
|
||||
self.assertEqual(score_hardware("NVIDIA GeForce RTX 3060 Ti", 8), Decimal("1"))
|
||||
|
||||
yesterday = (utcnow() - timedelta(days=1)).date()
|
||||
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
self._register("0xgen", "NODE-GEN", "1500")
|
||||
for index in range(19):
|
||||
self._register(f"0xpad{index:02d}", f"PAD-{index:02d}", "1500")
|
||||
self._register("0x40", "NODE-40", "1500")
|
||||
self._register("0x10", "NODE-10", "1500")
|
||||
self._cover_full_day("NODE-GEN", day_start, day_end)
|
||||
self._cover_full_day("NODE-40", day_start, day_end)
|
||||
self._cover_full_day("NODE-10", day_start, day_end)
|
||||
|
||||
genesis = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
json={"sn": "NODE-GEN", "gpu_model": "NVIDIA GeForce RTX 4090", "vram_gb": 24},
|
||||
)
|
||||
self.assertEqual(genesis.status_code, 200, genesis.text)
|
||||
self.assertEqual(genesis.json()["display_node_id"], 1001)
|
||||
self.assertEqual(genesis.json()["gpu_generation"], "40")
|
||||
self.assertEqual(genesis.json()["recovery_days"], 30)
|
||||
still = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
json={"sn": "NODE-GEN", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
||||
)
|
||||
self.assertEqual(still.json()["recovery_days"], 30)
|
||||
|
||||
high = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
json={"sn": "NODE-40", "gpu_model": "NVIDIA GeForce RTX 4090", "vram_gb": 24, "cpu_count": 16, "memory_gb": 64},
|
||||
)
|
||||
self.assertEqual(high.status_code, 200, high.text)
|
||||
self.assertEqual(high.json()["display_node_id"], 1021)
|
||||
self.assertEqual(high.json()["gpu_generation"], "40")
|
||||
self.assertEqual(high.json()["recovery_days"], 210)
|
||||
self.assertEqual(high.json()["recoverable_stake_amount"], "1500.00000000")
|
||||
locked = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
json={"sn": "NODE-40", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
||||
)
|
||||
self.assertEqual(locked.json()["gpu_generation"], "40")
|
||||
self.assertEqual(locked.json()["recovery_days"], 210)
|
||||
|
||||
low = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
json={"sn": "NODE-10", "gpu_model": "NVIDIA GeForce GTX 1050", "vram_gb": 2},
|
||||
)
|
||||
self.assertEqual(low.status_code, 200, low.text)
|
||||
self.assertEqual(low.json()["display_node_id"], 1022)
|
||||
self.assertEqual(low.json()["gpu_generation"], "10")
|
||||
self.assertEqual(low.json()["recovery_days"], 540)
|
||||
|
||||
settled = self.client.post(
|
||||
"/api/v1/yield/settle",
|
||||
headers=self._admin_headers(),
|
||||
params={"yield_date": yesterday.isoformat()},
|
||||
)
|
||||
self.assertEqual(settled.status_code, 200, settled.text)
|
||||
genesis_points = money(Decimal(1500) / Decimal(30))
|
||||
high_points = money(Decimal(1500) / Decimal(210))
|
||||
low_points = money(Decimal(1500) / Decimal(540))
|
||||
self.assertEqual(genesis_points, Decimal("50.00000000"))
|
||||
self.assertEqual(self._yield_row("NODE-GEN").points_delta, genesis_points)
|
||||
self.assertEqual(self._yield_row("NODE-40").points_delta, high_points)
|
||||
self.assertEqual(self._yield_row("NODE-10").points_delta, low_points)
|
||||
with SessionLocal() as session:
|
||||
first = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-GEN"))
|
||||
fast = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-40"))
|
||||
slow = session.scalar(select(ComputeNode).where(ComputeNode.sn == "NODE-10"))
|
||||
self.assertEqual(first.recoverable_stake_amount, money(Decimal(1500) - genesis_points))
|
||||
self.assertEqual(fast.recoverable_stake_amount, money(Decimal(1500) - high_points))
|
||||
self.assertEqual(slow.recoverable_stake_amount, money(Decimal(1500) - low_points))
|
||||
self.assertGreater(slow.license_expires_at, fast.license_expires_at)
|
||||
population = self.client.get("/api/v1/network")
|
||||
self.assertEqual(population.status_code, 200, population.text)
|
||||
self.assertEqual(population.json()["nodes"], 1022)
|
||||
|
||||
def test_whitelist_skips_single_ip_limit(self) -> None:
|
||||
shared = {"X-Real-IP": "203.0.113.10"}
|
||||
self._register("0xip1", "NODE-IP1", "1500", headers=shared)
|
||||
blocked = self.client.post(
|
||||
"/api/v1/users",
|
||||
json={"wallet_address": "0xip2"},
|
||||
)
|
||||
self.assertEqual(blocked.status_code, 200, blocked.text)
|
||||
denied = self.client.post(
|
||||
"/api/v1/nodes",
|
||||
headers={**self._auth("0xip2"), **shared},
|
||||
json={"wallet_address": "0xip2", "sn": "NODE-IP2", "purchase_amount": "1500"},
|
||||
)
|
||||
self.assertEqual(denied.status_code, 409, denied.text)
|
||||
self.assertEqual(denied.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
||||
|
||||
added = self.client.post(
|
||||
"/api/v1/admin/ip-whitelist",
|
||||
headers=self._admin_headers(),
|
||||
json={"ip_address": "203.0.113.10", "note": "机房出口"},
|
||||
)
|
||||
self.assertEqual(added.status_code, 200, added.text)
|
||||
allowed = self.client.post(
|
||||
"/api/v1/nodes",
|
||||
headers={**self._auth("0xip2"), **shared},
|
||||
json={"wallet_address": "0xip2", "sn": "NODE-IP2", "purchase_amount": "1500"},
|
||||
)
|
||||
self.assertEqual(allowed.status_code, 200, allowed.text)
|
||||
|
||||
self._register("0xip3", "NODE-IP3", "1500", headers={"X-Real-IP": "203.0.113.21"})
|
||||
crowded = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "203.0.113.21"},
|
||||
json={"sn": "NODE-IP1"},
|
||||
)
|
||||
self.assertEqual(crowded.status_code, 409, crowded.text)
|
||||
marked = self.client.post(
|
||||
"/api/v1/admin/nodes/NODE-IP1/whitelist",
|
||||
headers=self._admin_headers(),
|
||||
json={"is_whitelist": True},
|
||||
)
|
||||
self.assertEqual(marked.status_code, 200, marked.text)
|
||||
self.assertTrue(marked.json()["is_whitelist"])
|
||||
mounted = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "203.0.113.21"},
|
||||
json={"sn": "NODE-IP1"},
|
||||
)
|
||||
self.assertEqual(mounted.status_code, 200, mounted.text)
|
||||
|
||||
removed = self.client.delete(
|
||||
f"/api/v1/admin/ip-whitelist/{added.json()['id']}",
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
self.assertEqual(removed.status_code, 200, removed.text)
|
||||
listing = self.client.get("/api/v1/admin/ip-whitelist", headers=self._admin_headers())
|
||||
self.assertNotIn("203.0.113.10", [row["ip_address"] for row in listing.json()])
|
||||
|
||||
def test_datacenter_subnet_and_hardware_fingerprint(self) -> None:
|
||||
fingerprint = "a" * 64
|
||||
other = "b" * 64
|
||||
registered = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"wallet_address": "0xdc01", "username": "room", "password": "secret1"},
|
||||
)
|
||||
self.assertEqual(registered.status_code, 200, registered.text)
|
||||
headers = {"Authorization": f"Bearer {registered.json()['access_token']}"}
|
||||
short = self.client.post(
|
||||
"/api/v1/me/datacenter-applications",
|
||||
headers=headers,
|
||||
json={
|
||||
"contact": "room@example.com",
|
||||
"node_count": 4,
|
||||
"subnet_ip": "198.51.100.0/24",
|
||||
"hardware_proof": "RTX 4090 x 4",
|
||||
"gpu_generation": "40",
|
||||
},
|
||||
)
|
||||
self.assertEqual(short.status_code, 400, short.text)
|
||||
self.assertEqual(short.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
||||
low = self.client.post(
|
||||
"/api/v1/me/datacenter-applications",
|
||||
headers=headers,
|
||||
json={
|
||||
"contact": "room@example.com",
|
||||
"node_count": 8,
|
||||
"subnet_ip": "198.51.100.0/24",
|
||||
"hardware_proof": "GTX 1080 x 8",
|
||||
"gpu_generation": "40",
|
||||
},
|
||||
)
|
||||
self.assertEqual(low.status_code, 400, low.text)
|
||||
created = self.client.post(
|
||||
"/api/v1/me/datacenter-applications",
|
||||
headers=headers,
|
||||
json={
|
||||
"contact": "room@example.com",
|
||||
"node_count": 8,
|
||||
"subnet_ip": "198.51.100.10/24",
|
||||
"hardware_proof": "8 台 RTX 4090",
|
||||
"gpu_generation": "40",
|
||||
},
|
||||
)
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
self.assertEqual(created.json()["subnet_ip"], "198.51.100.0/24")
|
||||
self.assertEqual(created.json()["status"], "pending")
|
||||
approved = self.client.post(
|
||||
f"/api/v1/admin/datacenter-applications/{created.json()['id']}/approve",
|
||||
headers=self._admin_headers(),
|
||||
)
|
||||
self.assertEqual(approved.status_code, 200, approved.text)
|
||||
listed = self.client.get("/api/v1/admin/ip-whitelist", headers=self._admin_headers())
|
||||
self.assertIn("198.51.100.0/24", [row["ip_address"] for row in listed.json()])
|
||||
|
||||
self._register("0xdc02", "NODE-DC1", "1500", headers={"X-Real-IP": "198.51.100.8"})
|
||||
self._register("0xdc03", "NODE-DC2", "1500", headers={"X-Real-IP": "198.51.100.9"})
|
||||
first = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "198.51.100.8"},
|
||||
json={"sn": "NODE-DC1", "gpu_model": "NVIDIA GeForce RTX 4090", "hardware_fingerprint": fingerprint},
|
||||
)
|
||||
self.assertEqual(first.status_code, 200, first.text)
|
||||
cloned = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "198.51.100.9"},
|
||||
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce RTX 4090", "hardware_fingerprint": fingerprint},
|
||||
)
|
||||
self.assertEqual(cloned.status_code, 409, cloned.text)
|
||||
self.assertEqual(cloned.json()["detail"], FINGERPRINT_CLASH)
|
||||
second = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "198.51.100.9"},
|
||||
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce RTX 3060", "hardware_fingerprint": other},
|
||||
)
|
||||
self.assertEqual(second.status_code, 200, second.text)
|
||||
cheap = self.client.post(
|
||||
"/api/v1/node/heartbeat",
|
||||
headers={"X-Real-IP": "198.51.100.9"},
|
||||
json={"sn": "NODE-DC2", "gpu_model": "NVIDIA GeForce GTX 1080", "hardware_fingerprint": other},
|
||||
)
|
||||
self.assertEqual(cheap.status_code, 409, cheap.text)
|
||||
self.assertEqual(cheap.json()["detail"], DATACENTER_BATCH_MESSAGE)
|
||||
|
||||
def _auth(self, wallet: str) -> dict[str, str]:
|
||||
logged = self.client.post("/api/v1/auth/login", json={"wallet_address": wallet})
|
||||
self.assertEqual(logged.status_code, 200, logged.text)
|
||||
@@ -366,12 +611,12 @@ class CoreFlowTest(unittest.TestCase):
|
||||
self.assertEqual(logged.json()["role"], "admin")
|
||||
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
|
||||
|
||||
def _register(self, wallet: str, sn: str, amount: str) -> None:
|
||||
def _register(self, wallet: str, sn: str, amount: str, headers: dict[str, str] | None = None) -> None:
|
||||
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
|
||||
self.assertEqual(user.status_code, 200, user.text)
|
||||
node = self.client.post(
|
||||
"/api/v1/nodes",
|
||||
headers=self._auth(wallet),
|
||||
headers={**self._auth(wallet), **(headers or {})},
|
||||
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
|
||||
)
|
||||
self.assertEqual(node.status_code, 200, node.text)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,560;9..144,650&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/assets/index-B6RF37c6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C3jgSG4Q.css">
|
||||
<script type="module" crossorigin src="/assets/index-G_zbj9YV.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Xg5W9LDE.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -44,14 +44,25 @@ python -m app
|
||||
|
||||
## 产出公式
|
||||
|
||||
授权金额按授权起止日期的天数线性释放,再乘当天表现系数。单节点累计积分不超过购买金额。
|
||||
节点按激活顺序编号。第 1 台显示为 `1001`,第 2 台为 `1002`,即 `display_node_id = 1000 + 激活序号`。首页和控制台的全网节点数同样加上 1000:3 台在线显示为 1003。机器序列号仍用于心跳和客户端,不对外当作节点编号。
|
||||
|
||||
编号 `1001` 到 `1020` 是创世特权节点,回本固定为 30 天,每日基础释放 = `1500 / 30` = 50。上报显卡只会记录型号,不会改掉这 30 天。从 `1021` 起按显卡世代回本。
|
||||
|
||||
同一个公网 IP 只能绑定一台普通节点。再挂第二台时会拒绝,并提示去申请机房白名单。白名单可以是单 IP,也可以是网段,例如 `203.0.113.0/24`;请求 IP 落在网段里,或节点本身被标成白名单,就可以挂多台。客户端用心跳上报硬件指纹,由显卡 UUID、主板序列号和网卡地址合成,数据库里唯一。指纹重复会按虚拟机克隆拒绝。机房申请至少 5 台,且证明材料不能包含 10 系、20 系或核显;管理员通过后,该网段自动写入白名单。已经进网段的机器如果上报 10 系及更低显卡,不能混入这批授权。订单审核开通时不占用管理员自己的 IP,节点客户端第一次心跳才绑定地址。
|
||||
|
||||
心跳上报显卡后,世代会被锁定,并决定 1500 USDT 质押的回本天数。当日积分 = 购买金额 / 回本天数 × 当日表现系数。表现系数就是平台收益加权系数。积分优先冲减待返还质押,直到质押返还完毕。
|
||||
|
||||
- 创世节点 `1001`–`1020`:30 天,每日基础释放 = `1500 / 30`
|
||||
- 40 系及以上,例如 RTX 4090、4080、4070:210 天,每日基础释放 = `1500 / 210`
|
||||
- 30 系,例如 RTX 3090、3080、3070、3060 Ti、3060:365 天,每日基础释放 = `1500 / 365`
|
||||
- 10 系及更低、20 系,以及核显:540 天,每日基础释放 = `1500 / 540`
|
||||
- 在线率 = 当天授权窗口内的在线秒数 / 当天授权有效秒数
|
||||
- 任务响应率 = 校验通过且执行成功的任务数 / 当天下发任务数
|
||||
- 当天没有下发任务时,表现系数 = 在线率
|
||||
- 有任务时,表现系数 = `0.7 × 在线率 + 0.3 × 响应率`
|
||||
- 算力产出 = 购买金额 × 表现系数
|
||||
- 当日积分 = 算力产出 / 授权天数
|
||||
- 第 21 台及之后,未上报显卡前按 540 天计算。一旦识别到型号就锁定,之后不再改档
|
||||
|
||||
硬件权重仍会记录:RTX 3060 及以上为 `1.0`,RTX 20 系和 GTX 1060–1660 为 `0.5`,更旧或更低的独显为 `0.05`,没有独显为 `0.02`。回本速度看世代,不看这个权重。10 系的回本天数长于 12 个月授权时,授权结束时间会延到回本周期结束。
|
||||
|
||||
开放的在线区间如果最后心跳已超过 15 分钟,只计算到最后一次心跳。
|
||||
|
||||
@@ -110,7 +121,7 @@ journalctl -u hypernode.service | grep 初始密码
|
||||
| POST | `/users` | 只登记钱包,不设置密码 |
|
||||
| POST | `/nodes` | 登记节点,必须是该钱包本人或管理员 |
|
||||
| GET | `/nodes/{sn}` | 查看节点 |
|
||||
| POST | `/node/heartbeat` | 心跳。已开通节点必须额外带 `token` |
|
||||
| POST | `/node/heartbeat` | 心跳。已开通节点必须带 `token`,客户端同时上报显卡、显存、CPU 和内存 |
|
||||
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
|
||||
| POST | `/node/task/report` | 回传 `sn`、`task_id`、`success`、`nonce` |
|
||||
| GET | `/nodes/{sn}/contribution?yield_date=YYYY-MM-DD` | 预览某日产出,不入账 |
|
||||
|
||||
Reference in New Issue
Block a user