按显卡世代和创世编号结算回本,机房批量接入需审核网段并用硬件指纹防克隆。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
"""机房大客户申请,以及网段内禁止混入 10 系显卡、硬件指纹去重。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import case, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.clock import utcnow
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, DatacenterApplication, DatacenterStatus
|
||||
from app.services.hardware import GENERATION_10, GENERATION_30, GENERATION_40, classify_generation
|
||||
from app.services.ip_guard import DATACENTER_BATCH_MESSAGE, add_whitelist_ip, matching_whitelist, normalize_admin_ip
|
||||
|
||||
MIN_DATACENTER_NODES = 5
|
||||
FINGERPRINT_CLASH = "硬件指纹重复,疑似虚拟机克隆或刷量"
|
||||
_FINGERPRINT = re.compile(r"^[a-f0-9]{64}$")
|
||||
_LOW_END = re.compile(r"10\s*系|20\s*系|GTX|核显|1050|1060|1070|1080|1650|1660|2060|2070|2080", re.IGNORECASE)
|
||||
|
||||
|
||||
def assert_fingerprint(session: Session, fingerprint: str | None, node_id: int | None = None) -> str | None:
|
||||
"""同一块物理机不能挂第二台节点。空指纹不占唯一索引。"""
|
||||
text = (fingerprint or "").strip().lower()
|
||||
if not text:
|
||||
return None
|
||||
if _FINGERPRINT.fullmatch(text) is None:
|
||||
raise AppError("硬件指纹格式不正确")
|
||||
query = select(ComputeNode.id).where(ComputeNode.hardware_fingerprint == text)
|
||||
if node_id is not None:
|
||||
query = query.where(ComputeNode.id != node_id)
|
||||
if session.scalar(query) is not None:
|
||||
raise AppError(FINGERPRINT_CLASH, 409)
|
||||
return text
|
||||
|
||||
|
||||
def assert_subnet_gpu(session: Session, ip: str, gpu_model: str | None) -> None:
|
||||
"""已经批准的机房网段里,不允许再挂 10 系及更低的卡。"""
|
||||
address = (ip or "").strip()
|
||||
if not address or "/" in address:
|
||||
return
|
||||
entry = matching_whitelist(session, address)
|
||||
if entry is None or "/" not in entry:
|
||||
return
|
||||
model = (gpu_model or "").strip()
|
||||
if not model:
|
||||
return
|
||||
generation, _days = classify_generation(model)
|
||||
if generation == GENERATION_10:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 409)
|
||||
|
||||
|
||||
def submit_application(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
contact: str,
|
||||
node_count: int,
|
||||
subnet_ip: str,
|
||||
hardware_proof: str,
|
||||
gpu_generation: str,
|
||||
) -> DatacenterApplication:
|
||||
subnet = _require_subnet(subnet_ip)
|
||||
_require_threshold(node_count, gpu_generation, hardware_proof)
|
||||
existing = session.scalar(
|
||||
select(DatacenterApplication.id).where(
|
||||
DatacenterApplication.subnet_ip == subnet,
|
||||
DatacenterApplication.status.in_((DatacenterStatus.PENDING, DatacenterStatus.APPROVED)),
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
raise AppError("这个网段已有待审核或已通过的机房申请", 409)
|
||||
row = DatacenterApplication(
|
||||
user_id=user_id,
|
||||
contact=contact.strip(),
|
||||
node_count=node_count,
|
||||
subnet_ip=subnet,
|
||||
hardware_proof=hardware_proof.strip(),
|
||||
gpu_generation=gpu_generation,
|
||||
status=DatacenterStatus.PENDING,
|
||||
created_at=utcnow(),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def list_applications(session: Session, user_id: int | None = None) -> list[DatacenterApplication]:
|
||||
pending_first = case((DatacenterApplication.status == DatacenterStatus.PENDING, 0), else_=1)
|
||||
query = select(DatacenterApplication).options(selectinload(DatacenterApplication.user))
|
||||
if user_id is not None:
|
||||
query = query.where(DatacenterApplication.user_id == user_id)
|
||||
return list(session.scalars(query.order_by(pending_first, DatacenterApplication.id.desc())).all())
|
||||
|
||||
|
||||
def approve_application(session: Session, application_id: int) -> DatacenterApplication:
|
||||
row = _pending(session, application_id)
|
||||
_require_threshold(row.node_count, row.gpu_generation, row.hardware_proof)
|
||||
try:
|
||||
add_whitelist_ip(session, row.subnet_ip, note=f"机房申请 #{row.id} {row.contact}")
|
||||
except AppError as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
row.status = DatacenterStatus.APPROVED
|
||||
row.reviewed_at = utcnow()
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def reject_application(session: Session, application_id: int, note: str | None = None) -> DatacenterApplication:
|
||||
row = _pending(session, application_id)
|
||||
row.status = DatacenterStatus.REJECTED
|
||||
row.note = (note or "").strip() or None
|
||||
row.reviewed_at = utcnow()
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _pending(session: Session, application_id: int) -> DatacenterApplication:
|
||||
row = session.scalar(
|
||||
select(DatacenterApplication)
|
||||
.options(selectinload(DatacenterApplication.user))
|
||||
.where(DatacenterApplication.id == application_id)
|
||||
)
|
||||
if row is None:
|
||||
raise AppError("机房申请不存在", 404)
|
||||
if row.status != DatacenterStatus.PENDING:
|
||||
raise AppError("只有待审核的机房申请可以处理", 409)
|
||||
return row
|
||||
|
||||
|
||||
def _require_subnet(value: str) -> str:
|
||||
text = value.strip()
|
||||
if "/" not in text:
|
||||
raise AppError("请填写机房出口网段,例如 203.0.113.0/24")
|
||||
return normalize_admin_ip(text)
|
||||
|
||||
|
||||
def _require_threshold(node_count: int, gpu_generation: str, hardware_proof: str) -> None:
|
||||
generation = (gpu_generation or "").strip()
|
||||
if node_count < MIN_DATACENTER_NODES or generation not in {GENERATION_30, GENERATION_40}:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 400)
|
||||
if _LOW_END.search(hardware_proof or ""):
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 400)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""根据心跳上报的显卡型号和显存,给出节点算力权重。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
HIGH = Decimal("1")
|
||||
MID = Decimal("0.5")
|
||||
LOW = Decimal("0.05")
|
||||
NONE = Decimal("0.02")
|
||||
DEFAULT_WEIGHT = LOW
|
||||
GENERATION_40 = "40"
|
||||
GENERATION_30 = "30"
|
||||
GENERATION_10 = "10"
|
||||
RECOVERY_40_DAYS = 210
|
||||
RECOVERY_30_DAYS = 365
|
||||
RECOVERY_10_DAYS = 540
|
||||
NODE_ID_BASE = 1000
|
||||
GENESIS_COUNT = 20
|
||||
GENESIS_RECOVERY_DAYS = 30
|
||||
|
||||
_MODEL_NUMBER = re.compile(r"(RTX|GTX|GT)\s*(\d{3,4})")
|
||||
_DATACENTER = ("H100", "H200", "A100", "A800", "A6000", "A40", "A30", "L40")
|
||||
_INTEGRATED = ("INTEL", "UHD", "IRIS", "BASIC DISPLAY", "MICROSOFT")
|
||||
|
||||
|
||||
@dataclass
|
||||
class HardwareReport:
|
||||
gpu_model: str | None
|
||||
vram_gb: float | None
|
||||
cpu_count: int | None
|
||||
memory_gb: float | None
|
||||
|
||||
|
||||
def display_node_id(node_index: int) -> int:
|
||||
"""第 1 台显示 1001,第 2 台显示 1002。"""
|
||||
return NODE_ID_BASE + node_index
|
||||
|
||||
|
||||
def is_genesis(node_index: int) -> bool:
|
||||
return 1 <= node_index <= GENESIS_COUNT
|
||||
|
||||
|
||||
def displayed_population(actual: int) -> int:
|
||||
"""全网台数展示时加上 1000 的基数。3 台在线显示为 1003。"""
|
||||
return NODE_ID_BASE + actual
|
||||
|
||||
|
||||
def score_hardware(gpu_model: str | None, vram_gb: float | None) -> Decimal:
|
||||
"""高端主流卡 1.0,中端 0.5,旧卡或低端独显 0.05,没有独显 0.02。"""
|
||||
name = (gpu_model or "").strip()
|
||||
upper = name.upper()
|
||||
if not name or _is_integrated(upper):
|
||||
return NONE
|
||||
if any(token in upper for token in _DATACENTER):
|
||||
return HIGH
|
||||
match = _MODEL_NUMBER.search(upper)
|
||||
if match is not None:
|
||||
return _from_model(match.group(1), int(match.group(2)))
|
||||
if vram_gb is not None and vram_gb >= 8:
|
||||
return HIGH
|
||||
if vram_gb is not None and vram_gb >= 4:
|
||||
return MID
|
||||
return LOW
|
||||
|
||||
|
||||
def classify_generation(gpu_model: str | None) -> tuple[str, int]:
|
||||
"""40 系及以上 210 天,30 系 365 天,其余包括 10 系和核显 540 天。"""
|
||||
upper = (gpu_model or "").strip().upper()
|
||||
if not upper or _is_integrated(upper):
|
||||
return GENERATION_10, RECOVERY_10_DAYS
|
||||
if any(token in upper for token in _DATACENTER):
|
||||
return GENERATION_40, RECOVERY_40_DAYS
|
||||
match = _MODEL_NUMBER.search(upper)
|
||||
if match is not None and match.group(1) == "RTX":
|
||||
number = int(match.group(2))
|
||||
if number >= 4000:
|
||||
return GENERATION_40, RECOVERY_40_DAYS
|
||||
if number >= 3000:
|
||||
return GENERATION_30, RECOVERY_30_DAYS
|
||||
return GENERATION_10, RECOVERY_10_DAYS
|
||||
|
||||
|
||||
def _is_integrated(upper: str) -> bool:
|
||||
if any(token in upper for token in ("NVIDIA", "GEFORCE", "QUADRO")):
|
||||
return False
|
||||
if any(token in upper for token in _INTEGRATED):
|
||||
return True
|
||||
return "RADEON" in upper and "RX" not in upper
|
||||
|
||||
|
||||
def _from_model(family: str, number: int) -> Decimal:
|
||||
if family == "RTX":
|
||||
if number >= 3060:
|
||||
return HIGH
|
||||
return MID
|
||||
if family == "GTX":
|
||||
if number >= 1060:
|
||||
return MID
|
||||
return LOW
|
||||
return LOW
|
||||
@@ -13,6 +13,9 @@ from app.config import settings
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, NodeStatus, UptimeInterval
|
||||
from app.services.common import get_node_by_sn
|
||||
from app.services.datacenter import assert_fingerprint, assert_subnet_gpu
|
||||
from app.services.ip_guard import assert_ip_available
|
||||
from app.services.hardware import GENESIS_RECOVERY_DAYS, HardwareReport, classify_generation, is_genesis, score_hardware
|
||||
|
||||
|
||||
def assert_node_token(node: ComputeNode, token: str | None) -> None:
|
||||
@@ -24,14 +27,33 @@ def assert_node_token(node: ComputeNode, token: str | None) -> None:
|
||||
raise AppError("节点 Token 不正确", 403)
|
||||
|
||||
|
||||
def record_heartbeat(session: Session, sn: str, token: str | None = None, now: datetime | None = None) -> ComputeNode:
|
||||
def record_heartbeat(
|
||||
session: Session,
|
||||
sn: str,
|
||||
token: str | None = None,
|
||||
now: datetime | None = None,
|
||||
hardware: HardwareReport | None = None,
|
||||
client_ip: str | None = None,
|
||||
hardware_fingerprint: str | None = None,
|
||||
) -> ComputeNode:
|
||||
"""刷新最后活跃时间。离线节点收到心跳后重新打开一段在线区间。"""
|
||||
moment = now or utcnow()
|
||||
node = get_node_by_sn(session, sn.strip())
|
||||
assert_node_token(node, token)
|
||||
if node.license_expires_at <= moment:
|
||||
raise AppError("节点授权已到期", 403)
|
||||
bound_ip = (client_ip or "").strip() or None
|
||||
if bound_ip:
|
||||
assert_ip_available(session, bound_ip, node=node, now=moment)
|
||||
reported_gpu = hardware.gpu_model if hardware is not None and hardware.gpu_model else node.gpu_model
|
||||
assert_subnet_gpu(session, bound_ip, reported_gpu)
|
||||
node.client_ip = bound_ip
|
||||
fingerprint = assert_fingerprint(session, hardware_fingerprint, node.id)
|
||||
if fingerprint:
|
||||
node.hardware_fingerprint = fingerprint
|
||||
|
||||
if hardware is not None:
|
||||
_apply_hardware(node, hardware)
|
||||
node.last_heartbeat_at = moment
|
||||
node.updated_at = moment
|
||||
if node.status != NodeStatus.ONLINE:
|
||||
@@ -66,6 +88,26 @@ def mark_stale_nodes(session: Session, now: datetime | None = None) -> list[str]
|
||||
return changed
|
||||
|
||||
|
||||
def _apply_hardware(node: ComputeNode, hardware: HardwareReport) -> None:
|
||||
node.gpu_model = (hardware.gpu_model or "").strip()[:128] or None
|
||||
node.vram_gb = hardware.vram_gb
|
||||
node.cpu_count = hardware.cpu_count
|
||||
node.memory_gb = hardware.memory_gb
|
||||
node.hardware_weight = float(score_hardware(node.gpu_model, node.vram_gb))
|
||||
if node.generation_locked or not node.gpu_model:
|
||||
return
|
||||
generation, recovery_days = classify_generation(node.gpu_model)
|
||||
node.gpu_generation = generation
|
||||
node.generation_locked = True
|
||||
if is_genesis(node.node_index):
|
||||
node.recovery_days = GENESIS_RECOVERY_DAYS
|
||||
return
|
||||
node.recovery_days = recovery_days
|
||||
horizon = node.licensed_at + timedelta(days=recovery_days)
|
||||
if horizon > node.license_expires_at:
|
||||
node.license_expires_at = horizon
|
||||
|
||||
|
||||
def _ensure_open_interval(session: Session, node_id: int, started_at: datetime) -> None:
|
||||
opened = session.scalar(
|
||||
select(UptimeInterval).where(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""单 IP 只允许挂载一台普通节点。白名单单 IP、白名单网段和白名单节点跳过这条限制。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.clock import utcnow
|
||||
from app.errors import AppError
|
||||
from app.models import ComputeNode, IPWhitelist
|
||||
|
||||
IP_LIMIT_MESSAGE = "该 IP 已绑定过设备,非白名单 IP 仅限挂载一台"
|
||||
DATACENTER_BATCH_MESSAGE = "机房/批量接入需满足最低 5 台且全系 30/40 显卡门槛,请前往申请机房白名单"
|
||||
|
||||
|
||||
def client_ip(request: Request) -> str:
|
||||
"""优先取反向代理写下的真实地址,否则用连接对端。"""
|
||||
real = (request.headers.get("x-real-ip") or "").split(",")[0].strip()
|
||||
if real:
|
||||
return _loose_ip(real)
|
||||
forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
|
||||
if forwarded:
|
||||
return _loose_ip(forwarded)
|
||||
host = request.client.host if request.client is not None else ""
|
||||
return _loose_ip(host)
|
||||
|
||||
|
||||
def normalize_admin_ip(value: str) -> str:
|
||||
"""单 IP 保持地址本身,网段规范成网络地址,例如 192.168.1.10/24 变成 192.168.1.0/24。"""
|
||||
text = value.strip()
|
||||
if text.lower().startswith("::ffff:"):
|
||||
text = text[7:]
|
||||
if "/" in text:
|
||||
try:
|
||||
return str(ipaddress.ip_network(text, strict=False))
|
||||
except ValueError as exc:
|
||||
raise AppError("IP 或网段格式不正确") from exc
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError as exc:
|
||||
raise AppError("IP 或网段格式不正确") from exc
|
||||
|
||||
|
||||
def assert_ip_available(session: Session, ip: str, node: ComputeNode | None = None, now: datetime | None = None) -> None:
|
||||
"""白名单 IP,或节点已标记 is_whitelist 时,不限制同一地址上的节点数量。"""
|
||||
address = _loose_ip(ip)
|
||||
if not address:
|
||||
return
|
||||
if node is not None and node.is_whitelist:
|
||||
return
|
||||
if matching_whitelist(session, address) is not None:
|
||||
return
|
||||
moment = now or utcnow()
|
||||
query = select(ComputeNode.id).where(
|
||||
ComputeNode.client_ip == address,
|
||||
ComputeNode.is_whitelist.is_(False),
|
||||
ComputeNode.license_expires_at > moment,
|
||||
)
|
||||
if node is not None:
|
||||
query = query.where(ComputeNode.id != node.id)
|
||||
if session.scalar(query) is not None:
|
||||
raise AppError(DATACENTER_BATCH_MESSAGE, 409)
|
||||
|
||||
|
||||
def list_whitelist(session: Session) -> list[IPWhitelist]:
|
||||
return list(session.scalars(select(IPWhitelist).order_by(IPWhitelist.id.desc())).all())
|
||||
|
||||
|
||||
def add_whitelist_ip(session: Session, ip: str, note: str | None = None) -> IPWhitelist:
|
||||
address = normalize_admin_ip(ip)
|
||||
if _ip_listed(session, address):
|
||||
raise AppError("这个 IP 已在白名单中", 409)
|
||||
row = IPWhitelist(ip_address=address, note=(note or "").strip() or None, created_at=utcnow())
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def remove_whitelist_ip(session: Session, entry_id: int) -> IPWhitelist:
|
||||
row = session.get(IPWhitelist, entry_id)
|
||||
if row is None:
|
||||
raise AppError("白名单 IP 不存在", 404)
|
||||
session.delete(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def matching_whitelist(session: Session, address: str) -> str | None:
|
||||
"""请求 IP 落在某条单 IP 或网段里时,返回那条白名单原文。"""
|
||||
entries = list(session.scalars(select(IPWhitelist.ip_address)).all())
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return address if address in entries else None
|
||||
subnet_hit = None
|
||||
exact_hit = None
|
||||
for entry in entries:
|
||||
if "/" in entry:
|
||||
try:
|
||||
if ip in ipaddress.ip_network(entry, strict=False):
|
||||
subnet_hit = entry
|
||||
except ValueError:
|
||||
continue
|
||||
elif entry == str(ip):
|
||||
exact_hit = entry
|
||||
return subnet_hit or exact_hit
|
||||
|
||||
|
||||
def _ip_listed(session: Session, address: str) -> bool:
|
||||
return session.scalar(select(IPWhitelist.id).where(IPWhitelist.ip_address == address)) is not None
|
||||
|
||||
|
||||
def _loose_ip(value: str) -> str:
|
||||
text = (value or "").strip()
|
||||
if text.lower().startswith("::ffff:"):
|
||||
text = text[7:]
|
||||
if not text:
|
||||
return ""
|
||||
try:
|
||||
return str(ipaddress.ip_address(text))
|
||||
except ValueError:
|
||||
return text[:64]
|
||||
+160
-6
@@ -72,23 +72,172 @@ def list_orders(session: Session, user_id: int | None = None) -> list[LicenseOrd
|
||||
return list(session.scalars(query.order_by(pending_first, LicenseOrder.id.desc())).all())
|
||||
|
||||
|
||||
def render_client(sn: str, token: str, base_url: str) -> str:
|
||||
config = json.dumps({"sn": sn, "token": token, "base": base_url.rstrip("/")}, ensure_ascii=True)
|
||||
return f"""# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
|
||||
_CLIENT = """# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
CONFIG = json.loads({config!r})
|
||||
CONFIG = json.loads(__CONFIG__)
|
||||
|
||||
|
||||
def hardware():
|
||||
gpu_model, vram_gb, gpu_uuid = _gpu()
|
||||
return {
|
||||
"sn": CONFIG["sn"],
|
||||
"token": CONFIG["token"],
|
||||
"gpu_model": gpu_model,
|
||||
"vram_gb": vram_gb,
|
||||
"cpu_count": os.cpu_count(),
|
||||
"memory_gb": _memory_gb(),
|
||||
"hardware_fingerprint": _fingerprint(gpu_uuid),
|
||||
}
|
||||
|
||||
|
||||
def _fingerprint(gpu_uuid):
|
||||
board = _board_serial() or ""
|
||||
mac = f"{uuid.getnode():012x}"
|
||||
material = "|".join([gpu_uuid or "", board, mac])
|
||||
if not material.strip("|"):
|
||||
return None
|
||||
return hashlib.sha256(material.encode()).hexdigest()
|
||||
|
||||
|
||||
def _board_serial():
|
||||
if os.name == "nt":
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["wmic", "baseboard", "get", "serialnumber"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=8,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
lines = [line.strip() for line in output.splitlines() if line.strip() and "SerialNumber" not in line]
|
||||
return lines[0] if lines else None
|
||||
for path in ("/sys/class/dmi/id/board_serial", "/sys/class/dmi/id/product_uuid"):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
value = handle.read().strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value and value.lower() not in {"none", "to be filled by o.e.m."}:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _gpu():
|
||||
probed = _pynvml()
|
||||
if probed[0]:
|
||||
return probed
|
||||
return _nvidia_smi()
|
||||
|
||||
|
||||
def _pynvml():
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
return None, None, None
|
||||
try:
|
||||
pynvml.nvmlInit()
|
||||
best_name = None
|
||||
best_bytes = -1
|
||||
best_uuid = None
|
||||
for index in range(pynvml.nvmlDeviceGetCount()):
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
|
||||
name = pynvml.nvmlDeviceGetName(handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
total = int(pynvml.nvmlDeviceGetMemoryInfo(handle).total)
|
||||
if total > best_bytes:
|
||||
uid = pynvml.nvmlDeviceGetUUID(handle)
|
||||
if isinstance(uid, bytes):
|
||||
uid = uid.decode()
|
||||
best_name = str(name)
|
||||
best_bytes = total
|
||||
best_uuid = str(uid)
|
||||
pynvml.nvmlShutdown()
|
||||
except Exception:
|
||||
return None, None, None
|
||||
if not best_name or best_bytes < 0:
|
||||
return None, None, None
|
||||
return best_name, round(best_bytes / (1024 ** 3), 2), best_uuid
|
||||
|
||||
|
||||
def _nvidia_smi():
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=name,memory.total,uuid", "--format=csv,noheader,nounits"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=8,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None, None, None
|
||||
best_name = None
|
||||
best_mib = -1.0
|
||||
best_uuid = None
|
||||
for line in output.splitlines():
|
||||
parts = [part.strip() for part in line.split(",", 2)]
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
name, memory = parts[0], parts[1]
|
||||
gpu_uuid = parts[2] if len(parts) > 2 else None
|
||||
try:
|
||||
mib = float(memory)
|
||||
except ValueError:
|
||||
continue
|
||||
if mib > best_mib:
|
||||
best_name = name or None
|
||||
best_mib = mib
|
||||
best_uuid = gpu_uuid or None
|
||||
if best_name is None:
|
||||
return None, None, None
|
||||
return best_name, round(best_mib / 1024, 2), best_uuid
|
||||
|
||||
|
||||
def _memory_gb():
|
||||
if os.name == "nt":
|
||||
class MemoryStatus(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwLength", ctypes.c_ulong),
|
||||
("dwMemoryLoad", ctypes.c_ulong),
|
||||
("ullTotalPhys", ctypes.c_ulonglong),
|
||||
("ullAvailPhys", ctypes.c_ulonglong),
|
||||
("ullTotalPageFile", ctypes.c_ulonglong),
|
||||
("ullAvailPageFile", ctypes.c_ulonglong),
|
||||
("ullTotalVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailVirtual", ctypes.c_ulonglong),
|
||||
("ullAvailExtendedVirtual", ctypes.c_ulonglong),
|
||||
]
|
||||
|
||||
status = MemoryStatus()
|
||||
status.dwLength = ctypes.sizeof(MemoryStatus)
|
||||
if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)):
|
||||
return round(status.ullTotalPhys / (1024 ** 3), 2)
|
||||
return None
|
||||
try:
|
||||
with open("/proc/meminfo", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("MemTotal:"):
|
||||
return round(int(line.split()[1]) / 1024 / 1024, 2)
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def beat():
|
||||
body = json.dumps({{"sn": CONFIG["sn"], "token": CONFIG["token"]}}).encode()
|
||||
body = json.dumps(hardware()).encode()
|
||||
request = urllib.request.Request(
|
||||
CONFIG["base"] + "/api/v1/node/heartbeat",
|
||||
data=body,
|
||||
headers={{"Content-Type": "application/json"}},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
print(response.read().decode())
|
||||
@@ -104,6 +253,11 @@ if __name__ == "__main__":
|
||||
"""
|
||||
|
||||
|
||||
def render_client(sn: str, token: str, base_url: str) -> str:
|
||||
config = json.dumps({"sn": sn, "token": token, "base": base_url.rstrip("/")}, ensure_ascii=True)
|
||||
return _CLIENT.replace("__CONFIG__", repr(config))
|
||||
|
||||
|
||||
def _pending_order(session: Session, order_id: int) -> LicenseOrder:
|
||||
order = session.scalar(
|
||||
select(LicenseOrder)
|
||||
|
||||
+21
-4
@@ -10,8 +10,9 @@ from sqlalchemy.orm import Session, selectinload
|
||||
from app.clock import utcnow
|
||||
from app.models import ComputeNode, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
|
||||
from app.money import money, to_decimal
|
||||
from app.services.hardware import display_node_id, displayed_population, is_genesis
|
||||
from app.services.withdrawals import available_balance
|
||||
from app.services.yield_engine import compute_node_yield
|
||||
from app.services.yield_engine import network_yields
|
||||
|
||||
|
||||
def settled_points(session: Session, user_id: int | None = None) -> Decimal:
|
||||
@@ -47,13 +48,17 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||
if user_id is not None:
|
||||
query = query.where(ComputeNode.user_id == user_id)
|
||||
today = utcnow().date()
|
||||
scored = {node.id: computed for node, computed in network_yields(session, today)}
|
||||
snapshots = []
|
||||
for node in session.scalars(query).all():
|
||||
computed = compute_node_yield(session, node, today)
|
||||
computed = scored.get(node.id)
|
||||
snapshots.append(
|
||||
{
|
||||
"id": node.id,
|
||||
"sn": node.sn,
|
||||
"node_index": node.node_index,
|
||||
"display_node_id": display_node_id(node.node_index),
|
||||
"genesis": is_genesis(node.node_index),
|
||||
"user_id": node.user_id,
|
||||
"wallet_address": node.user.wallet_address,
|
||||
"purchase_amount": node.purchase_amount,
|
||||
@@ -65,8 +70,20 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||
"callback_url": node.callback_url,
|
||||
"node_token": node.node_token if user_id is not None else None,
|
||||
"activated": node.node_token is not None,
|
||||
"online_rate": computed.online_rate,
|
||||
"today_points": computed.points_delta if computed.eligible else Decimal("0"),
|
||||
"online_rate": computed.online_rate if computed is not None else Decimal("0"),
|
||||
"today_points": computed.points_delta if computed is not None and computed.eligible else Decimal("0"),
|
||||
"gpu_model": node.gpu_model,
|
||||
"vram_gb": node.vram_gb,
|
||||
"cpu_count": node.cpu_count,
|
||||
"memory_gb": node.memory_gb,
|
||||
"hardware_weight": node.hardware_weight if node.hardware_weight is not None else 0.05,
|
||||
"gpu_generation": node.gpu_generation or "10",
|
||||
"recovery_days": node.recovery_days or 540,
|
||||
"generation_locked": bool(node.generation_locked),
|
||||
"recoverable_stake_amount": node.recoverable_stake_amount,
|
||||
"is_whitelist": bool(node.is_whitelist),
|
||||
"client_ip": node.client_ip,
|
||||
"hardware_fingerprint": node.hardware_fingerprint,
|
||||
}
|
||||
)
|
||||
return snapshots
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from decimal import Decimal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -15,6 +15,14 @@ from app.errors import AppError
|
||||
from app.models import ComputeNode, NodeStatus, User, UserRole
|
||||
from app.money import money
|
||||
from app.services.common import get_user_by_wallet
|
||||
from app.services.ip_guard import assert_ip_available
|
||||
from app.services.hardware import (
|
||||
DEFAULT_WEIGHT,
|
||||
GENERATION_10,
|
||||
GENESIS_RECOVERY_DAYS,
|
||||
RECOVERY_10_DAYS,
|
||||
is_genesis,
|
||||
)
|
||||
|
||||
|
||||
def register_user(
|
||||
@@ -52,16 +60,23 @@ def register_node(
|
||||
purchase_amount: Decimal,
|
||||
callback_url: str | None = None,
|
||||
node_token: str | None = None,
|
||||
client_ip: str | None = None,
|
||||
) -> ComputeNode:
|
||||
user = get_user_by_wallet(session, wallet_address.strip())
|
||||
serial = sn.strip()
|
||||
if session.scalar(select(ComputeNode).where(ComputeNode.sn == serial)) is not None:
|
||||
raise AppError("节点序列号已存在", 409)
|
||||
bound_ip = (client_ip or "").strip() or None
|
||||
if bound_ip:
|
||||
assert_ip_available(session, bound_ip)
|
||||
callback = _normalize_callback(callback_url)
|
||||
started = utcnow()
|
||||
node_index = int(session.scalar(select(func.max(ComputeNode.node_index))) or 0) + 1
|
||||
recovery_days = GENESIS_RECOVERY_DAYS if is_genesis(node_index) else RECOVERY_10_DAYS
|
||||
node = ComputeNode(
|
||||
user_id=user.id,
|
||||
sn=serial,
|
||||
node_index=node_index,
|
||||
purchase_amount=money(purchase_amount),
|
||||
license_months=settings.license_months,
|
||||
licensed_at=started,
|
||||
@@ -69,6 +84,13 @@ def register_node(
|
||||
status=NodeStatus.OFFLINE,
|
||||
callback_url=callback,
|
||||
node_token=node_token,
|
||||
hardware_weight=float(DEFAULT_WEIGHT),
|
||||
gpu_generation=GENERATION_10,
|
||||
recovery_days=recovery_days,
|
||||
generation_locked=False,
|
||||
recoverable_stake_amount=money(purchase_amount),
|
||||
is_whitelist=False,
|
||||
client_ip=bound_ip,
|
||||
created_at=started,
|
||||
updated_at=started,
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
3. 任务响应率 = 校验通过且执行成功的任务数 / 当日内下发任务数。
|
||||
4. 当天没有下发任务时,表现系数等于在线率,避免调度器故障误伤节点。
|
||||
有任务时,表现系数 = 在线权重 × 在线率 + 任务权重 × 响应率。
|
||||
5. 算力产出 = 授权金额 × 表现系数。
|
||||
当日积分 = 算力产出 / 授权天数。
|
||||
6. 单节点累计积分不超过授权金额。已存在的当日流水不会被覆盖。
|
||||
5. 显卡世代决定回本天数:40 系及以上 210 天,30 系 365 天,10 系及以下或核显 540 天。
|
||||
每日基础释放 = 购买金额 / 回本天数。
|
||||
当日积分 = 每日基础释放 × 表现系数。表现系数就是当日平台收益加权系数。
|
||||
6. 积分优先冲减 recoverable_stake_amount,直到质押金返还完毕。已存在的当日流水不会被覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -28,6 +29,7 @@ from app.errors import AppError
|
||||
from app.models import ComputeNode, ComputeTask, NodeYield, TaskStatus, UptimeInterval, YieldStatus
|
||||
from app.money import money, rate, ratio, to_decimal
|
||||
from app.services.common import get_node_by_sn
|
||||
from app.services.hardware import DEFAULT_WEIGHT, RECOVERY_10_DAYS
|
||||
from app.services.tasks import expire_overdue_tasks
|
||||
|
||||
|
||||
@@ -47,6 +49,9 @@ class YieldComputation:
|
||||
compute_output: Decimal = Decimal("0")
|
||||
points_delta: Decimal = Decimal("0")
|
||||
cycle_days: int = 0
|
||||
base_release: Decimal = Decimal("0")
|
||||
hardware_weight: Decimal = Decimal("0")
|
||||
remaining_points: Decimal = Decimal("0")
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -68,9 +73,9 @@ def compute_node_yield(
|
||||
day_start, day_end = day_bounds(yield_date)
|
||||
window_start = max(day_start, node.licensed_at)
|
||||
window_end = min(day_end, node.license_expires_at)
|
||||
cycle_days = (node.license_expires_at.date() - node.licensed_at.date()).days
|
||||
if window_end <= window_start or cycle_days <= 0:
|
||||
return YieldComputation(eligible=False, yield_date=yield_date, cycle_days=max(cycle_days, 0))
|
||||
recovery_days = int(node.recovery_days or RECOVERY_10_DAYS)
|
||||
if window_end <= window_start or recovery_days <= 0:
|
||||
return YieldComputation(eligible=False, yield_date=yield_date, cycle_days=max(recovery_days, 0))
|
||||
|
||||
eligible_seconds = int((window_end - window_start).total_seconds())
|
||||
online_seconds = min(_online_seconds(session, node, window_start, window_end, moment), eligible_seconds)
|
||||
@@ -94,9 +99,11 @@ def compute_node_yield(
|
||||
applied_online = to_decimal(settings.online_weight)
|
||||
applied_task = to_decimal(settings.task_weight)
|
||||
performance = rate(applied_online * online_rate + applied_task * response_rate)
|
||||
base_release = to_decimal(node.purchase_amount) / Decimal(recovery_days)
|
||||
hardware_weight = to_decimal(node.hardware_weight if node.hardware_weight is not None else DEFAULT_WEIGHT)
|
||||
remaining = _remaining_stake(session, node)
|
||||
points_delta = min(money(base_release * performance), remaining)
|
||||
compute_output = money(to_decimal(node.purchase_amount) * performance)
|
||||
points_delta = money(compute_output / Decimal(cycle_days))
|
||||
points_delta = min(points_delta, _remaining_points(session, node))
|
||||
|
||||
return YieldComputation(
|
||||
eligible=True,
|
||||
@@ -112,16 +119,20 @@ def compute_node_yield(
|
||||
performance=performance,
|
||||
compute_output=compute_output,
|
||||
points_delta=points_delta,
|
||||
cycle_days=cycle_days,
|
||||
cycle_days=recovery_days,
|
||||
base_release=base_release,
|
||||
hardware_weight=hardware_weight,
|
||||
remaining_points=remaining,
|
||||
)
|
||||
|
||||
|
||||
def settle_yield_date(session: Session, yield_date: date, now: datetime | None = None) -> SettleSummary:
|
||||
"""清算指定 UTC 日。每个节点当天已有流水时跳过,保证脚本重复执行不会重复入账。"""
|
||||
def network_yields(
|
||||
session: Session,
|
||||
yield_date: date,
|
||||
now: datetime | None = None,
|
||||
) -> list[tuple[ComputeNode, YieldComputation]]:
|
||||
"""算出授权覆盖该日的全部节点。积分按各节点自己的显卡世代释放。"""
|
||||
moment = now or utcnow()
|
||||
if yield_date > moment.date():
|
||||
raise AppError("不能清算未来日期")
|
||||
expire_overdue_tasks(session, moment)
|
||||
day_start, day_end = day_bounds(yield_date)
|
||||
nodes = session.scalars(
|
||||
select(ComputeNode)
|
||||
@@ -131,23 +142,32 @@ def settle_yield_date(session: Session, yield_date: date, now: datetime | None =
|
||||
)
|
||||
.order_by(ComputeNode.id)
|
||||
).all()
|
||||
return [(node, compute_node_yield(session, node, yield_date, moment)) for node in nodes]
|
||||
|
||||
|
||||
def settle_yield_date(session: Session, yield_date: date, now: datetime | None = None) -> SettleSummary:
|
||||
"""清算指定 UTC 日。每个节点当天已有流水时跳过,保证脚本重复执行不会重复入账。"""
|
||||
moment = now or utcnow()
|
||||
if yield_date > moment.date():
|
||||
raise AppError("不能清算未来日期")
|
||||
expire_overdue_tasks(session, moment)
|
||||
created = 0
|
||||
skipped = 0
|
||||
total = Decimal("0")
|
||||
partial = yield_date == moment.date()
|
||||
for node in nodes:
|
||||
for node, computed in network_yields(session, yield_date, moment):
|
||||
exists = session.scalar(
|
||||
select(NodeYield.id).where(NodeYield.node_id == node.id, NodeYield.yield_date == yield_date)
|
||||
)
|
||||
if exists is not None:
|
||||
skipped += 1
|
||||
continue
|
||||
computed = compute_node_yield(session, node, yield_date, moment)
|
||||
if not computed.eligible:
|
||||
continue
|
||||
try:
|
||||
with session.begin_nested():
|
||||
session.add(_to_row(node, computed, partial))
|
||||
node.recoverable_stake_amount = money(computed.remaining_points - computed.points_delta)
|
||||
except IntegrityError:
|
||||
skipped += 1
|
||||
continue
|
||||
@@ -204,6 +224,15 @@ def _proven_end(interval: UptimeInterval, node: ComputeNode, now: datetime) -> d
|
||||
return now
|
||||
|
||||
|
||||
def _remaining_stake(session: Session, node: ComputeNode) -> Decimal:
|
||||
if node.recoverable_stake_amount is not None:
|
||||
remaining = to_decimal(node.recoverable_stake_amount)
|
||||
if remaining <= 0:
|
||||
return Decimal("0")
|
||||
return money(remaining)
|
||||
return _remaining_points(session, node)
|
||||
|
||||
|
||||
def _remaining_points(session: Session, node: ComputeNode) -> Decimal:
|
||||
paid_rows = session.scalars(select(NodeYield.points_delta).where(NodeYield.node_id == node.id)).all()
|
||||
paid = sum((to_decimal(item) for item in paid_rows), Decimal("0"))
|
||||
|
||||
Reference in New Issue
Block a user