Files
HyperNode-AI/app/services/orders.py
T
2026-10-11 01:57:18 +08:00

631 lines
19 KiB
Python

"""节点授权订单。付款凭证先入库,管理员通过后才开通节点。"""
from __future__ import annotations
import json
import secrets
from sqlalchemy import case, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import LicenseOrder, OrderStatus
from app.money import money
from app.services.registry import register_node
def place_order(session: Session, user_id: int, tx_id: str) -> LicenseOrder:
digest = tx_id.strip()
if session.scalar(select(LicenseOrder.id).where(LicenseOrder.tx_id == digest)) is not None:
raise AppError("这笔交易已经提交过", 409)
order = LicenseOrder(
user_id=user_id,
amount=money(settings.license_price),
asset_symbol=settings.license_asset.strip().upper() or "USDT",
status=OrderStatus.PENDING,
tx_id=digest,
created_at=utcnow(),
)
session.add(order)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise AppError("这笔交易已经提交过", 409) from exc
return order
def approve_order(session: Session, order_id: int) -> LicenseOrder:
order = _pending_order(session, order_id)
token = secrets.token_urlsafe(24)
node = register_node(
session,
order.user.wallet_address,
f"HN{order.id:06d}",
order.amount,
node_token=token,
)
order.status = OrderStatus.APPROVED
order.node = node
order.reviewed_at = utcnow()
session.flush()
return order
def reject_order(session: Session, order_id: int, note: str | None = None) -> LicenseOrder:
order = _pending_order(session, order_id)
order.status = OrderStatus.REJECTED
order.note = note
order.reviewed_at = utcnow()
session.flush()
return order
def list_orders(session: Session, user_id: int | None = None) -> list[LicenseOrder]:
pending_first = case((LicenseOrder.status == OrderStatus.PENDING, 0), else_=1)
query = select(LicenseOrder).options(selectinload(LicenseOrder.user), selectinload(LicenseOrder.node))
if user_id is not None:
query = query.where(LicenseOrder.user_id == user_id)
return list(session.scalars(query.order_by(pending_first, LicenseOrder.id.desc())).all())
_CLIENT = """# HyperNode 挂机客户端。这个文件里有节点 Token,不要发给别人。
import ctypes
import hashlib
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
CONFIG = json.loads(__CONFIG__)
SHARES = 0
_REFERENCE_MH = (
("4090", 130.0),
("4080 SUPER", 95.0),
("4080", 90.0),
("4070 TI SUPER", 75.0),
("4070 TI", 70.0),
("4070 SUPER", 62.0),
("4070", 55.0),
("3090 TI", 125.0),
("3090", 115.0),
("3080 TI", 105.0),
("3080", 95.0),
("3070 TI", 65.0),
("3070", 60.0),
("3060 TI", 48.5),
("3060", 40.0),
)
def current_mode():
mode = os.environ.get("HYPERNODE_MODE") or CONFIG.get("current_mode") or "pow_mining"
return mode if mode in {"pow_mining", "gpu_rental"} else "pow_mining"
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 and probed.get("name"):
return probed
probed = _nvidia_smi()
return probed or {}
def _blank_gpu():
return {
"name": None,
"vram_gb": None,
"uuid": None,
"vram_used_mb": None,
"vram_total_mb": None,
"gpu_temp": None,
"fan_speed": None,
"power_usage": None,
"utilization": None,
}
def _pynvml():
try:
import pynvml
except ImportError:
return None
try:
pynvml.nvmlInit()
best = None
best_bytes = -1
for index in range(pynvml.nvmlDeviceGetCount()):
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
name = pynvml.nvmlDeviceGetName(handle)
if isinstance(name, bytes):
name = name.decode()
memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
total = int(memory.total)
if total <= best_bytes:
continue
uid = pynvml.nvmlDeviceGetUUID(handle)
if isinstance(uid, bytes):
uid = uid.decode()
best_bytes = total
best = _blank_gpu()
best.update({
"name": str(name),
"vram_gb": round(total / (1024 ** 3), 2),
"uuid": str(uid),
"vram_used_mb": int(round(int(memory.used) / (1024 ** 2))),
"vram_total_mb": int(round(total / (1024 ** 2))),
"gpu_temp": _nvml_call(pynvml.nvmlDeviceGetTemperature, handle, pynvml.NVML_TEMPERATURE_GPU),
"fan_speed": _nvml_call(pynvml.nvmlDeviceGetFanSpeed, handle),
"power_usage": _nvml_watts(pynvml, handle),
"utilization": _nvml_util(pynvml, handle),
})
pynvml.nvmlShutdown()
except Exception:
return None
return best
def _nvml_call(func, *args):
try:
value = func(*args)
return int(value)
except Exception:
return None
def _nvml_watts(pynvml, handle):
try:
return round(pynvml.nvmlDeviceGetPowerUsage(handle) / 1000, 1)
except Exception:
return None
def _nvml_util(pynvml, handle):
try:
return int(pynvml.nvmlDeviceGetUtilizationRates(handle).gpu)
except Exception:
return None
def _nvidia_smi():
query = "name,memory.total,memory.used,uuid,temperature.gpu,fan.speed,power.draw,utilization.gpu"
try:
output = subprocess.check_output(
["nvidia-smi", "--query-gpu=" + query, "--format=csv,noheader,nounits"],
stderr=subprocess.DEVNULL,
timeout=8,
text=True,
)
except (OSError, subprocess.SubprocessError):
return None
best = None
best_mib = -1.0
for line in output.splitlines():
parts = [part.strip() for part in line.split(",")]
if len(parts) < 8:
continue
total = _number(parts[-7])
if total is None or total <= best_mib:
continue
best_mib = total
best = _blank_gpu()
best.update({
"name": ",".join(parts[:-7]).strip() or None,
"vram_gb": round(total / 1024, 2),
"vram_total_mb": int(round(total)),
"vram_used_mb": _int_number(parts[-6]),
"uuid": parts[-5] or None,
"gpu_temp": _int_number(parts[-4]),
"fan_speed": _int_number(parts[-3]),
"power_usage": _number(parts[-2]),
"utilization": _int_number(parts[-1]),
})
return best
def _number(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _int_number(value):
number = _number(value)
if number is None:
return None
return int(round(number))
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():
global SHARES
gpu = _gpu()
payload = hardware_from(gpu)
body = json.dumps(payload).encode()
request = urllib.request.Request(
CONFIG["base"] + "/api/v1/node/heartbeat",
data=body,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=20) as response:
reply = json.loads(response.read().decode())
SHARES += 1
_save_shares(SHARES)
_report(gpu, reply, SHARES)
_run_tasks(reply)
def hardware_from(gpu):
card = gpu or {}
return {
"sn": CONFIG["sn"],
"token": CONFIG["token"],
"gpu_model": card.get("name"),
"vram_gb": card.get("vram_gb"),
"cpu_count": os.cpu_count(),
"memory_gb": _memory_gb(),
"hardware_fingerprint": _fingerprint(card.get("uuid")),
}
def _run_tasks(reply):
try:
listed = _fetch_tasks()
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace").strip()
_log("WARN", f"tasks {exc.code} {detail}")
listed = []
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
_log("WARN", f"tasks {exc}")
listed = []
for task in _collect_tasks(reply, listed):
try:
_execute_task(task)
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace").strip()
_log("WARN", f"task {exc.code} {detail}")
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as exc:
_log("WARN", f"task {exc}")
def _fetch_tasks():
token = CONFIG.get("token") or ""
url = CONFIG["base"] + "/api/v1/node/" + urllib.parse.quote(CONFIG["sn"]) + "/tasks"
if token:
url += "?token=" + urllib.parse.quote(token)
request = urllib.request.Request(url)
with urllib.request.urlopen(request, timeout=20) as response:
body = json.loads(response.read().decode())
return body if isinstance(body, list) else []
def _collect_tasks(reply, listed):
found = []
seen = set()
pool = []
assigned = (reply or {}).get("assigned_task")
if isinstance(assigned, dict):
pool.append(assigned)
if isinstance(listed, list):
pool.extend(item for item in listed if isinstance(item, dict))
for task in pool:
task_id = task.get("task_id")
if not task_id or task_id in seen:
continue
seen.add(task_id)
found.append(task)
return found
def _execute_task(task):
payload = task.get("payload") or {}
kind = task.get("task_type") or "task"
symbol = payload.get("symbol") or ""
label = kind + (f" · {symbol}" if symbol else "")
_log("TASK", "executing · " + label)
result = _solve(kind, payload)
_submit_task(task, result)
formula = result.get("formula") or result.get("output") or result.get("summary") or "done"
ic = result.get("ic")
if isinstance(ic, (int, float)):
_log("TASK", f"reported · IC {float(ic):.4f} · {formula}")
else:
_log("TASK", f"reported · {formula}")
def _solve(kind, payload):
if kind == "alpha_mining":
return _mine_alpha(payload)
if kind == "llm_inference":
return {"output": "inference finished", "summary": "inference finished"}
if kind == "lora_training":
return {"patch_bytes": 4096, "summary": "adapter ready"}
if kind == "stress_test":
return {"summary": "stress pass"}
return {"summary": "completed"}
def _mine_alpha(payload):
operators = payload.get("operators") or ["rank"]
if isinstance(operators, str):
operators = [operators]
names = [str(item).strip() for item in operators if str(item).strip()]
if not names:
names = ["rank"]
symbol = str(payload.get("symbol") or "CLOSE")
try:
seed = int(payload.get("seed") or 0)
except (TypeError, ValueError):
seed = 0
window = 4 + (abs(seed) % 24)
expr = "close"
for name in reversed(names):
expr = f"{name}({expr}, {window})"
window = max(2, window // 2)
digest = hashlib.sha256(f"{seed}|{symbol}|{expr}".encode()).digest()
unit = int.from_bytes(digest[:4], "big") / 4294967295
sign = 1 if digest[4] % 2 == 0 else -1
ic = round(sign * (0.03 + unit * 0.09), 4)
return {"formula": expr, "ic": ic, "symbol": symbol}
def _submit_task(task, result):
payload = task.get("payload") or {}
nonce = str(payload.get("nonce") or "")
if len(nonce) < 8:
raise ValueError("task nonce missing")
body = {
"sn": CONFIG["sn"],
"task_id": task.get("task_id"),
"success": True,
"nonce": nonce,
"token": CONFIG.get("token"),
"result": result,
}
request = urllib.request.Request(
CONFIG["base"] + "/api/v1/node/task/report",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode())
def _report(gpu, reply, shares):
card = gpu or {}
name = card.get("name") or "GPU not detected"
vram = card.get("vram_gb")
vram_text = f"{vram:.2f} GB" if isinstance(vram, (int, float)) else "n/a"
_log("INFO", f"{name} · {vram_text}")
_log("HARDWARE", _hardware_line(card))
if current_mode() == "gpu_rental":
load = card.get("utilization")
load_text = f"{load}% Load" if load is not None else "n/a"
_log("TASK", f"[AI Task Executing] · {load_text}")
else:
rate = _hashrate(name)
rate_text = f"{rate:.1f} MH/s" if rate is not None else "n/a"
_log("HASHRATE", f"{rate_text} · Accepted {shares}")
weight = reply.get("hardware_weight")
weight_text = f"{float(weight):.1f}" if isinstance(weight, (int, float)) else "n/a"
node_id = reply.get("display_node_id") or "----"
status = reply.get("status") or "unknown"
_log("HEARTBEAT", f"{status} · Node #{node_id} · Weight {weight_text}")
_log("RECOVERY", _recovery_line(reply))
print(flush=True)
def _hardware_line(card):
used = card.get("vram_used_mb")
total = card.get("vram_total_mb")
vram = f"{used} / {total} MB" if used is not None and total is not None else "n/a"
return " · ".join([
_suffix(card.get("gpu_temp"), "°C"),
"Fan " + _suffix(card.get("fan_speed"), "%"),
_suffix(card.get("power_usage"), " W"),
"VRAM " + vram,
])
def _recovery_line(reply):
days = int(reply.get("recovery_days") or 0)
purchase = _decimal(reply.get("purchase_amount"))
remaining = _decimal(reply.get("recoverable_stake_amount"))
estimate = reply.get("estimated_daily_points") or "0"
if purchase is not None and remaining is not None and purchase > 0:
done = max(0.0, (purchase - remaining) / purchase * 100)
progress = f"{done:.1f}% returned"
else:
progress = "n/a"
title = "30-day fast recovery" if days == 30 else f"{days}-day recovery"
return f"{title} · {progress} · Est. Points {estimate}"
def _hashrate(name):
upper = (name or "").upper().replace("-", " ")
for token, rate in _REFERENCE_MH:
if token in upper:
return rate
return None
def _suffix(value, suffix):
if value is None:
return "n/a"
return f"{value}{suffix}"
def _decimal(value):
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _log(tag, message):
color = {
"INFO": "36",
"HARDWARE": "33",
"HASHRATE": "32",
"TASK": "32",
"HEARTBEAT": "96",
"RECOVERY": "33",
"WARN": "31",
}.get(tag, "37")
label = f"[{tag}]".ljust(12)
if sys.stdout.isatty():
label = f"\033[1;{color}m{label}\033[0m"
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"{stamp} {label} {message}", flush=True)
def _share_path():
folder = os.path.dirname(os.path.abspath(__file__))
return os.path.join(folder, "." + CONFIG["sn"] + ".shares")
def _load_shares():
try:
with open(_share_path(), encoding="utf-8") as handle:
return max(0, int(handle.read().strip() or "0"))
except (OSError, ValueError):
return 0
def _save_shares(count):
try:
with open(_share_path(), "w", encoding="utf-8") as handle:
handle.write(str(count))
except OSError:
return None
def _enable_ansi():
if os.name != "nt":
return
try:
kernel = ctypes.windll.kernel32
handle = kernel.GetStdHandle(-11)
mode = ctypes.c_ulong()
if kernel.GetConsoleMode(handle, ctypes.byref(mode)):
kernel.SetConsoleMode(handle, mode.value | 0x0004)
except Exception:
return None
if __name__ == "__main__":
_enable_ansi()
SHARES = _load_shares()
_log("INFO", "HyperNode client · " + CONFIG["sn"] + " · " + current_mode())
while True:
try:
beat()
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace").strip()
_log("WARN", f"heartbeat {exc.code} {detail}")
except (urllib.error.URLError, TimeoutError, OSError) as exc:
_log("WARN", str(exc))
time.sleep(60)
"""
def render_client(sn: str, token: str, base_url: str) -> str:
config = json.dumps(
{
"sn": sn,
"token": token,
"base": base_url.rstrip("/"),
"current_mode": "pow_mining",
},
ensure_ascii=True,
)
return _CLIENT.replace("__CONFIG__", repr(config))
def _pending_order(session: Session, order_id: int) -> LicenseOrder:
order = session.scalar(
select(LicenseOrder)
.options(selectinload(LicenseOrder.user), selectinload(LicenseOrder.node))
.where(LicenseOrder.id == order_id)
)
if order is None:
raise AppError("订单不存在", 404)
if order.status != OrderStatus.PENDING:
raise AppError("只有待审核的订单可以处理", 409)
return order