diff --git a/app/api.py b/app/api.py
index 3431381..620f47b 100644
--- a/app/api.py
+++ b/app/api.py
@@ -14,7 +14,17 @@ from app.config import settings
from app.database import get_db
from app.deps import AdminUser, CurrentUser
from app.errors import AppError
-from app.models import ComputeNode, ComputeTask, LicenseOrder, User, UserRole, WithdrawalLog, WithdrawalStatus
+from app.models import (
+ ComputeNode,
+ ComputeTask,
+ DatacenterApplication,
+ IPWhitelist,
+ LicenseOrder,
+ User,
+ UserRole,
+ WithdrawalLog,
+ WithdrawalStatus,
+)
from app.money import money
from app.schemas import (
AdminUserOut,
@@ -32,6 +42,9 @@ from app.schemas import (
NodeCreate,
NodeOut,
NodeView,
+ IPWhitelistCreate,
+ IPWhitelistOut,
+ WhitelistToggle,
RegisterIn,
SettleOut,
SummaryOut,
@@ -43,6 +56,9 @@ from app.schemas import (
WithdrawalConfirm,
WithdrawalCreate,
WithdrawalOut,
+ DatacenterApply,
+ DatacenterOut,
+ DatacenterView,
WithdrawalReject,
WithdrawalView,
YieldOut,
@@ -50,7 +66,11 @@ from app.schemas import (
from app.security import create_access_token
from app.services.auth import authenticate, register_account
from app.services.common import get_node_by_sn, get_user_by_wallet
+from app.services.datacenter import approve_application, list_applications, reject_application, submit_application
+from app.services.hardware import HardwareReport
+from app.services.hardware import display_node_id, displayed_population
from app.services.heartbeat import assert_node_token, record_heartbeat
+from app.services.ip_guard import add_whitelist_ip, client_ip, list_whitelist, remove_whitelist_ip
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
from app.services.portal import (
authorized_amount,
@@ -63,7 +83,7 @@ from app.services.portal import (
from app.services.registry import register_node, register_user
from app.services.tasks import list_pending_tasks, report_task
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
-from app.services.yield_engine import compute_node_yield, list_node_yields, settle_yield_date
+from app.services.yield_engine import compute_node_yield, list_node_yields, network_yields, settle_yield_date
router = APIRouter(prefix="/api/v1")
Db = Annotated[Session, Depends(get_db)]
@@ -81,6 +101,12 @@ def login(body: LoginIn, db: Db) -> TokenOut:
return _token(user)
+@router.get("/network")
+def network_population(db: Db) -> dict[str, int]:
+ nodes, online = node_counts(db)
+ return {"nodes": displayed_population(nodes), "online": displayed_population(online)}
+
+
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser) -> User:
return user
@@ -93,6 +119,7 @@ def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
).all()
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
+ network_total, network_online = node_counts(db)
return DashboardOut(
wallet_address=user.wallet_address,
username=user.username,
@@ -104,7 +131,10 @@ def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
orders=[_order_out(item) for item in list_orders(db, user.id)],
license_price=money(settings.license_price),
license_asset=settings.license_asset,
+ network_nodes=displayed_population(network_total),
+ network_online=displayed_population(network_online),
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
+ datacenter_applications=[_datacenter_out(item) for item in list_applications(db, user.id)],
)
@@ -128,8 +158,25 @@ def download_client(sn: str, request: Request, user: CurrentUser, db: Db) -> Res
@router.post("/me/nodes", response_model=NodeOut)
-def create_my_node(body: MeNodeCreate, user: CurrentUser, db: Db) -> NodeOut:
- return register_node(db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url)
+def create_my_node(body: MeNodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
+ return register_node(
+ db, user.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
+ )
+
+
+@router.post("/me/datacenter-applications", response_model=DatacenterOut)
+def create_datacenter_application(body: DatacenterApply, user: CurrentUser, db: Db) -> DatacenterOut:
+ return _datacenter_out(
+ submit_application(
+ db,
+ user.id,
+ body.contact,
+ body.node_count,
+ body.subnet_ip,
+ body.hardware_proof,
+ body.gpu_generation,
+ )
+ )
@router.post("/me/withdrawals", response_model=WithdrawalOut)
@@ -153,6 +200,46 @@ def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
return [NodeView.model_validate(item) for item in live_nodes(db)]
+@router.post("/admin/nodes/{sn}/whitelist", response_model=NodeView)
+def admin_toggle_node_whitelist(sn: str, body: WhitelistToggle, _: AdminUser, db: Db) -> NodeView:
+ node = get_node_by_sn(db, sn)
+ node.is_whitelist = body.is_whitelist
+ node.updated_at = utcnow()
+ db.flush()
+ snapshot = next(item for item in live_nodes(db) if item["sn"] == node.sn)
+ return NodeView.model_validate(snapshot)
+
+
+@router.get("/admin/ip-whitelist", response_model=list[IPWhitelistOut])
+def admin_ip_whitelist(_: AdminUser, db: Db) -> list[IPWhitelist]:
+ return list_whitelist(db)
+
+
+@router.post("/admin/ip-whitelist", response_model=IPWhitelistOut)
+def admin_add_ip_whitelist(body: IPWhitelistCreate, _: AdminUser, db: Db) -> IPWhitelist:
+ return add_whitelist_ip(db, body.ip_address, body.note)
+
+
+@router.delete("/admin/ip-whitelist/{entry_id}", response_model=IPWhitelistOut)
+def admin_remove_ip_whitelist(entry_id: int, _: AdminUser, db: Db) -> IPWhitelist:
+ return remove_whitelist_ip(db, entry_id)
+
+
+@router.get("/admin/datacenter-applications", response_model=list[DatacenterView])
+def admin_datacenter_applications(_: AdminUser, db: Db) -> list[DatacenterView]:
+ return [_datacenter_view(row) for row in list_applications(db)]
+
+
+@router.post("/admin/datacenter-applications/{application_id}/approve", response_model=DatacenterView)
+def admin_approve_datacenter(application_id: int, _: AdminUser, db: Db) -> DatacenterView:
+ return _datacenter_view(approve_application(db, application_id))
+
+
+@router.post("/admin/datacenter-applications/{application_id}/reject", response_model=DatacenterView)
+def admin_reject_datacenter(application_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> DatacenterView:
+ return _datacenter_view(reject_application(db, application_id, body.note))
+
+
@router.get("/admin/orders", response_model=list[OrderView])
def admin_orders(_: AdminUser, db: Db) -> list[OrderView]:
return [_order_view(row) for row in list_orders(db)]
@@ -204,9 +291,11 @@ def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
@router.post("/nodes", response_model=NodeOut)
-def create_node(body: NodeCreate, user: CurrentUser, db: Db) -> NodeOut:
+def create_node(body: NodeCreate, request: Request, user: CurrentUser, db: Db) -> NodeOut:
_must_own_wallet(user, body.wallet_address)
- return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
+ return register_node(
+ db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url, client_ip=client_ip(request)
+ )
@router.get("/nodes/{sn}", response_model=NodeOut)
@@ -215,13 +304,28 @@ def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
@router.post("/node/heartbeat", response_model=HeartbeatOut)
-def heartbeat(body: HeartbeatIn, db: Db) -> HeartbeatOut:
- node = record_heartbeat(db, body.sn, body.token)
+def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
+ hardware = None
+ if {"gpu_model", "vram_gb", "cpu_count", "memory_gb"} & body.model_fields_set:
+ hardware = HardwareReport(body.gpu_model, body.vram_gb, body.cpu_count, body.memory_gb)
+ node = record_heartbeat(
+ db,
+ body.sn,
+ body.token,
+ hardware=hardware,
+ client_ip=client_ip(request),
+ hardware_fingerprint=body.hardware_fingerprint,
+ )
return HeartbeatOut(
sn=node.sn,
+ display_node_id=node.display_node_id,
status=node.status,
last_heartbeat_at=node.last_heartbeat_at,
server_time=node.last_heartbeat_at,
+ hardware_weight=node.hardware_weight,
+ gpu_generation=node.gpu_generation,
+ recovery_days=node.recovery_days,
+ recoverable_stake_amount=node.recoverable_stake_amount,
)
@@ -240,7 +344,12 @@ def report(body: TaskReportIn, db: Db) -> TaskOut:
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
node = _owned_node(db, user, sn)
- computed = compute_node_yield(db, node, yield_date)
+ computed = next(
+ (item for current, item in network_yields(db, yield_date) if current.id == node.id),
+ None,
+ )
+ if computed is None:
+ computed = compute_node_yield(db, node, yield_date)
return ContributionOut(sn=node.sn, **computed.__dict__)
@@ -297,8 +406,8 @@ def _summary(db: Session) -> SummaryOut:
nodes, online = node_counts(db)
return SummaryOut(
users=users,
- nodes=nodes,
- online=online,
+ nodes=displayed_population(nodes),
+ online=displayed_population(online),
offline=nodes - online,
authorized_amount=authorized_amount(db),
settled_points=settled_points(db),
@@ -338,6 +447,17 @@ def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
)
+def _datacenter_out(row: DatacenterApplication) -> DatacenterOut:
+ return DatacenterOut.model_validate(row)
+
+
+def _datacenter_view(row: DatacenterApplication) -> DatacenterView:
+ return DatacenterView(
+ **_datacenter_out(row).model_dump(),
+ wallet_address=row.user.wallet_address,
+ )
+
+
def _order_out(order: LicenseOrder) -> OrderOut:
return OrderOut(
id=order.id,
@@ -349,6 +469,7 @@ def _order_out(order: LicenseOrder) -> OrderOut:
created_at=order.created_at,
reviewed_at=order.reviewed_at,
sn=order.node.sn if order.node is not None else None,
+ display_node_id=display_node_id(order.node.node_index) if order.node is not None else None,
)
diff --git a/app/models.py b/app/models.py
index da4a6f2..0d8258a 100644
--- a/app/models.py
+++ b/app/models.py
@@ -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):
"""一段连续在线时间。心跳首次上线时打开,超时或授权到期时关闭。"""
diff --git a/app/schema_upgrade.py b/app/schema_upgrade.py
index b483e67..70cdbb3 100644
--- a/app/schema_upgrade.py
+++ b/app/schema_upgrade.py
@@ -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:
diff --git a/app/schemas.py b/app/schemas.py
index 76753f0..c6a33a6 100644
--- a/app/schemas.py
+++ b/app/schemas.py
@@ -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()
diff --git a/app/services/datacenter.py b/app/services/datacenter.py
new file mode 100644
index 0000000..a84d67c
--- /dev/null
+++ b/app/services/datacenter.py
@@ -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)
diff --git a/app/services/hardware.py b/app/services/hardware.py
new file mode 100644
index 0000000..7a93176
--- /dev/null
+++ b/app/services/hardware.py
@@ -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
diff --git a/app/services/heartbeat.py b/app/services/heartbeat.py
index 497ccdf..8ae1626 100644
--- a/app/services/heartbeat.py
+++ b/app/services/heartbeat.py
@@ -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(
diff --git a/app/services/ip_guard.py b/app/services/ip_guard.py
new file mode 100644
index 0000000..ddfd4dd
--- /dev/null
+++ b/app/services/ip_guard.py
@@ -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]
diff --git a/app/services/orders.py b/app/services/orders.py
index dd02d62..07e2551 100644
--- a/app/services/orders.py
+++ b/app/services/orders.py
@@ -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)
diff --git a/app/services/portal.py b/app/services/portal.py
index e077940..f64836d 100644
--- a/app/services/portal.py
+++ b/app/services/portal.py
@@ -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
diff --git a/app/services/registry.py b/app/services/registry.py
index 4d49160..a8466ad 100644
--- a/app/services/registry.py
+++ b/app/services/registry.py
@@ -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,
)
diff --git a/app/services/yield_engine.py b/app/services/yield_engine.py
index fea8dea..1687ba4 100644
--- a/app/services/yield_engine.py
+++ b/app/services/yield_engine.py
@@ -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"))
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 67bda71..4ec6185 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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; }
diff --git a/frontend/src/views/AdminOverview.vue b/frontend/src/views/AdminOverview.vue
index 333f39a..e60fd80 100644
--- a/frontend/src/views/AdminOverview.vue
+++ b/frontend/src/views/AdminOverview.vue
@@ -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));
已注册钱包
[Genesis Special Node - 30 Days Recovery]
+创世特权节点 - 30天回本
{{ node.wallet_address }}
+{{ node.sn }}
{{ pct(node.online_rate) }} 今日在线率
还没有节点。
可以填写单 IP,或整个机房网段,例如 203.0.113.0/24。网段内的地址都可以挂载多台设备。
+ +| IP | 备注 | 添加时间 | |
|---|---|---|---|
| {{ row.ip_address }} | +{{ row.note || "—" }} | +{{ when(row.created_at) }} | ++ |
| 还没有豁免 IP。 | |||
| 单号 | 钱包 | 联系方式 | 节点数 | 网段 | 显卡 | 证明 | 状态 | ||
|---|---|---|---|---|---|---|---|---|---|
| #{{ row.id }} | +{{ row.wallet_address }} | +{{ row.contact }} | +{{ row.node_count }} | +{{ row.subnet_ip }} | +{{ row.gpu_generation }} 系 | +{{ row.hardware_proof }} | +{{ statusText[row.status] }} | ++ + + | ++ |
| 还没有机房申请。 | |||||||||