按显卡世代和创世编号结算回本,机房批量接入需审核网段并用硬件指纹防克隆。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-11 00:30:46 +08:00
parent 7d29be8b17
commit 0740016ad8
22 changed files with 1554 additions and 96 deletions
+143
View File
@@ -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)
+103
View File
@@ -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
+43 -1
View File
@@ -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(
+126
View File
@@ -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
View File
@@ -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
View File
@@ -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
+23 -1
View File
@@ -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,
)
+45 -16
View File
@@ -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"))