From 0740016ad806e2c3fa783c6bfb24502728ca265b Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 11 Oct 2026 00:30:46 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8C=89=E6=98=BE=E5=8D=A1=E4=B8=96=E4=BB=A3?= =?UTF-8?q?=E5=92=8C=E5=88=9B=E4=B8=96=E7=BC=96=E5=8F=B7=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=9B=9E=E6=9C=AC=EF=BC=8C=E6=9C=BA=E6=88=BF=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E9=9C=80=E5=AE=A1=E6=A0=B8=E7=BD=91=E6=AE=B5?= =?UTF-8?q?=E5=B9=B6=E7=94=A8=E7=A1=AC=E4=BB=B6=E6=8C=87=E7=BA=B9=E9=98=B2?= =?UTF-8?q?=E5=85=8B=E9=9A=86=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/api.py | 143 +++++++++- app/models.py | 64 +++++ app/schema_upgrade.py | 56 +++- app/schemas.py | 76 ++++- app/services/datacenter.py | 143 ++++++++++ app/services/hardware.py | 103 +++++++ app/services/heartbeat.py | 44 ++- app/services/ip_guard.py | 126 +++++++++ app/services/orders.py | 166 ++++++++++- app/services/portal.py | 25 +- app/services/registry.py | 24 +- app/services/yield_engine.py | 61 ++-- frontend/src/styles.css | 29 ++ frontend/src/views/AdminOverview.vue | 158 ++++++++++- frontend/src/views/Dashboard.vue | 73 ++++- frontend/src/views/Login.vue | 15 +- tests/test_core_flow.py | 267 +++++++++++++++++- web/assets/index-B6RF37c6.js | 26 -- web/assets/index-G_zbj9YV.js | 26 ++ ...{index-C3jgSG4Q.css => index-Xg5W9LDE.css} | 2 +- web/index.html | 4 +- 说明.md | 19 +- 22 files changed, 1554 insertions(+), 96 deletions(-) create mode 100644 app/services/datacenter.py create mode 100644 app/services/hardware.py create mode 100644 app/services/ip_guard.py delete mode 100644 web/assets/index-B6RF37c6.js create mode 100644 web/assets/index-G_zbj9YV.js rename web/assets/{index-C3jgSG4Q.css => index-Xg5W9LDE.css} (71%) 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)); 已注册钱包
- 节点 + 全网节点 {{ summary.nodes }} 在线 {{ summary.online }} · 离线 {{ summary.offline }}
@@ -147,7 +230,7 @@ onUnmounted(() => window.clearInterval(timer)); {{ money(row.amount) }} {{ row.asset_symbol }} {{ row.tx_id }} {{ statusText[row.status] }} - {{ row.sn || "—" }} + {{ row.display_node_id || row.sn || "—" }} @@ -163,19 +246,84 @@ onUnmounted(() => window.clearInterval(timer));
- {{ node.sn }} + {{ node.display_node_id || node.sn }} {{ statusText[node.status] }}
+
+ 白名单 + +
+

[Genesis Special Node - 30 Days Recovery]

+

创世特权节点 - 30天回本

{{ node.wallet_address }}

+

{{ node.sn }}

{{ pct(node.online_rate) }} 今日在线率

今日积分
{{ money(node.today_points) }}
心跳
{{ when(node.last_heartbeat_at) }}
+
绑定 IP
{{ node.client_ip || "—" }}

还没有节点。

+

IP 白名单设置

+
+

可以填写单 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] }} + + +
还没有机房申请。
+
+

提现审核

diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 12aa1a5..c994f7d 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -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)); 按当前在线实时估算
- 节点 - {{ board.nodes.length }} - {{ board.nodes.filter((node) => node.status === "online").length }} 台在线 + 全网节点 + {{ board.network_nodes }} + 在线 {{ board.network_online }}

* 算力积分兑换汇率:1 积分 = 0.8 ~ 1.0 USDT(根据全网算力动态浮动,当前基准约 0.95 USDT)

@@ -108,7 +143,7 @@ onUnmounted(() => window.clearInterval(timer));

{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}

审核通过前不能复制 Token,也不能下载客户端。

-

节点 {{ order.sn }}

+

节点 {{ order.display_node_id }}

@@ -116,14 +151,21 @@ onUnmounted(() => window.clearInterval(timer));
- {{ node.sn }} + {{ node.display_node_id || node.sn }} {{ node.activated ? statusText[node.status] : "待审核" }}
+

[Genesis Special Node - 30 Days Recovery]

+

创世特权节点 - 30天回本

{{ pct(node.online_rate) }} 今日在线

购买金额
{{ money(node.purchase_amount) }}
今日积分
{{ money(node.today_points) }}
最近心跳
{{ when(node.last_heartbeat_at) }}
+
显卡
{{ gpuText(node) }}
+
硬件权重
{{ node.hardware_weight }}
+
显卡世代
{{ generationText(node) }}
+
回本周期
{{ node.recovery_days }} 天
+
待返还质押
{{ money(node.recoverable_stake_amount || 0) }}
@@ -154,6 +196,27 @@ onUnmounted(() => window.clearInterval(timer)); +
+

机房白名单申请

+

同一公网出口批量接入,需至少 5 台且全部为 30 系或 40 系显卡。审核通过后,该网段内硬件指纹不同的机器可以一起挂载。

+ + + + +