订单需管理员审核后才开通节点,并更新登录页文案。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+68
-5
@@ -4,15 +4,17 @@ from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import case, func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.clock import utcnow
|
||||
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, User, UserRole, WithdrawalLog, WithdrawalStatus
|
||||
from app.models import ComputeNode, ComputeTask, LicenseOrder, User, UserRole, WithdrawalLog, WithdrawalStatus
|
||||
from app.money import money
|
||||
from app.schemas import (
|
||||
AdminUserOut,
|
||||
@@ -23,6 +25,9 @@ from app.schemas import (
|
||||
HeartbeatOut,
|
||||
LoginIn,
|
||||
MeNodeCreate,
|
||||
OrderCreate,
|
||||
OrderOut,
|
||||
OrderView,
|
||||
MeWithdrawalCreate,
|
||||
NodeCreate,
|
||||
NodeOut,
|
||||
@@ -45,7 +50,8 @@ 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.heartbeat import record_heartbeat
|
||||
from app.services.heartbeat import assert_node_token, record_heartbeat
|
||||
from app.services.orders import approve_order, list_orders, place_order, reject_order, render_client
|
||||
from app.services.portal import (
|
||||
authorized_amount,
|
||||
live_nodes,
|
||||
@@ -95,10 +101,32 @@ def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
|
||||
settled_points=settled_points(db, user.id),
|
||||
today_points=today,
|
||||
nodes=[NodeView.model_validate(item) for item in nodes],
|
||||
orders=[_order_out(item) for item in list_orders(db, user.id)],
|
||||
license_price=money(settings.license_price),
|
||||
license_asset=settings.license_asset,
|
||||
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/orders", response_model=OrderOut)
|
||||
def create_my_order(body: OrderCreate, user: CurrentUser, db: Db) -> OrderOut:
|
||||
return _order_out(place_order(db, user.id, body.tx_id))
|
||||
|
||||
|
||||
@router.get("/me/nodes/{sn}/client")
|
||||
def download_client(sn: str, request: Request, user: CurrentUser, db: Db) -> Response:
|
||||
node = _owned_node(db, user, sn)
|
||||
if not node.node_token:
|
||||
raise AppError("节点尚未开通,不能下载客户端", 403)
|
||||
script = render_client(node.sn, node.node_token, str(request.base_url))
|
||||
filename = f"hypernode-{node.sn}.py"
|
||||
return Response(
|
||||
content=script,
|
||||
media_type="text/x-python; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
@@ -125,6 +153,21 @@ def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
|
||||
return [NodeView.model_validate(item) for item in live_nodes(db)]
|
||||
|
||||
|
||||
@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)]
|
||||
|
||||
|
||||
@router.post("/admin/orders/{order_id}/approve", response_model=OrderView)
|
||||
def admin_approve_order(order_id: int, _: AdminUser, db: Db) -> OrderView:
|
||||
return _order_view(approve_order(db, order_id))
|
||||
|
||||
|
||||
@router.post("/admin/orders/{order_id}/reject", response_model=OrderView)
|
||||
def admin_reject_order(order_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> OrderView:
|
||||
return _order_view(reject_order(db, order_id, body.note))
|
||||
|
||||
|
||||
@router.get("/admin/withdrawals", response_model=list[WithdrawalView])
|
||||
def admin_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
|
||||
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
|
||||
@@ -173,7 +216,7 @@ 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)
|
||||
node = record_heartbeat(db, body.sn, body.token)
|
||||
return HeartbeatOut(
|
||||
sn=node.sn,
|
||||
status=node.status,
|
||||
@@ -183,12 +226,14 @@ def heartbeat(body: HeartbeatIn, db: Db) -> HeartbeatOut:
|
||||
|
||||
|
||||
@router.get("/node/{sn}/tasks", response_model=list[TaskOut])
|
||||
def pending_tasks(sn: str, db: Db) -> list[TaskOut]:
|
||||
def pending_tasks(sn: str, db: Db, token: str | None = None) -> list[TaskOut]:
|
||||
assert_node_token(get_node_by_sn(db, sn), token)
|
||||
return [_task_out(task) for task in list_pending_tasks(db, sn)]
|
||||
|
||||
|
||||
@router.post("/node/task/report", response_model=TaskOut)
|
||||
def report(body: TaskReportIn, db: Db) -> TaskOut:
|
||||
assert_node_token(get_node_by_sn(db, body.sn), body.token)
|
||||
return _task_out(report_task(db, body.sn, body.task_id, body.success, body.nonce))
|
||||
|
||||
|
||||
@@ -293,6 +338,24 @@ def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
|
||||
)
|
||||
|
||||
|
||||
def _order_out(order: LicenseOrder) -> OrderOut:
|
||||
return OrderOut(
|
||||
id=order.id,
|
||||
user_id=order.user_id,
|
||||
amount=order.amount,
|
||||
asset_symbol=order.asset_symbol,
|
||||
status=order.status,
|
||||
tx_id=order.tx_id,
|
||||
created_at=order.created_at,
|
||||
reviewed_at=order.reviewed_at,
|
||||
sn=order.node.sn if order.node is not None else None,
|
||||
)
|
||||
|
||||
|
||||
def _order_view(order: LicenseOrder) -> OrderView:
|
||||
return OrderView(**_order_out(order).model_dump(), wallet_address=order.user.wallet_address)
|
||||
|
||||
|
||||
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
|
||||
return WithdrawalView(
|
||||
**WithdrawalOut.model_validate(row).model_dump(),
|
||||
|
||||
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
|
||||
yield_hour: int = 0
|
||||
yield_minute: int = 5
|
||||
license_months: int = 12
|
||||
license_price: Decimal = Decimal("1500")
|
||||
license_asset: str = "USDT"
|
||||
disable_scheduler: bool = False
|
||||
jwt_secret: str = ""
|
||||
jwt_expire_minutes: int = 60 * 24
|
||||
|
||||
@@ -103,6 +103,12 @@ class UserRole(str, enum.Enum):
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
class OrderStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
|
||||
|
||||
@@ -118,6 +124,7 @@ class User(Base):
|
||||
nodes: Mapped[list[ComputeNode]] = relationship(back_populates="user")
|
||||
yields: Mapped[list[NodeYield]] = relationship(back_populates="user")
|
||||
withdrawals: Mapped[list[WithdrawalLog]] = relationship(back_populates="user")
|
||||
orders: Mapped[list[LicenseOrder]] = relationship(back_populates="user")
|
||||
|
||||
|
||||
class ComputeNode(Base):
|
||||
@@ -140,10 +147,12 @@ class ComputeNode(Base):
|
||||
status: Mapped[NodeStatus] = mapped_column(_enum(NodeStatus), default=NodeStatus.OFFLINE, index=True)
|
||||
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="挂机客户端凭证,审核通过后生成")
|
||||
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")
|
||||
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")
|
||||
@@ -249,6 +258,30 @@ class WithdrawalLog(Base):
|
||||
user: Mapped[User] = relationship(back_populates="withdrawals")
|
||||
|
||||
|
||||
class LicenseOrder(Base):
|
||||
"""节点授权订单。管理员通过后才创建节点并签发挂机 Token。"""
|
||||
|
||||
__tablename__ = "license_orders"
|
||||
__table_args__ = (
|
||||
CheckConstraint("amount > 0", name="ck_order_amount_positive"),
|
||||
Index("ix_order_user_status", "user_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||
amount: Mapped[Decimal] = mapped_column(MONEY, comment="购买金额,例如 1500 USDT")
|
||||
asset_symbol: Mapped[str] = mapped_column(String(32), default="USDT")
|
||||
status: Mapped[OrderStatus] = mapped_column(_enum(OrderStatus), default=OrderStatus.PENDING, index=True)
|
||||
tx_id: Mapped[str] = mapped_column(String(128), unique=True, comment="用户提交的链上交易哈希")
|
||||
node_id: Mapped[int | None] = mapped_column(ForeignKey("compute_nodes.id"), unique=True)
|
||||
note: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(UtcDateTime)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="orders")
|
||||
node: Mapped[ComputeNode | None] = relationship(back_populates="order")
|
||||
|
||||
|
||||
class AppSetting(Base):
|
||||
"""进程重启后仍要保留的运行时密钥,例如未写入环境变量的 JWT 密钥。"""
|
||||
|
||||
|
||||
+20
-11
@@ -7,22 +7,31 @@ from app.database import engine
|
||||
|
||||
def upgrade_schema() -> None:
|
||||
inspector = inspect(engine)
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
tables = set(inspector.get_table_names())
|
||||
statements: list[str] = []
|
||||
if "username" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN username VARCHAR(32)")
|
||||
if "password_hash" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN password_hash VARCHAR(255)")
|
||||
if "role" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN role VARCHAR(16) DEFAULT 'user' NOT NULL")
|
||||
if not statements and _has_username_index(inspector):
|
||||
need_username_index = False
|
||||
if "users" in tables:
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "username" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN username VARCHAR(32)")
|
||||
if "password_hash" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN password_hash VARCHAR(255)")
|
||||
if "role" not in columns:
|
||||
statements.append("ALTER TABLE users ADD COLUMN role VARCHAR(16) DEFAULT 'user' NOT NULL")
|
||||
need_username_index = not _has_username_index(inspector)
|
||||
if "compute_nodes" in tables:
|
||||
node_columns = {column["name"] for column in inspector.get_columns("compute_nodes")}
|
||||
if "node_token" not in node_columns:
|
||||
statements.append("ALTER TABLE compute_nodes ADD COLUMN node_token VARCHAR(128)")
|
||||
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:
|
||||
return
|
||||
with engine.begin() as conn:
|
||||
for statement in statements:
|
||||
conn.execute(text(statement))
|
||||
if not _has_username_index(inspector):
|
||||
if need_username_index:
|
||||
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
|
||||
|
||||
|
||||
|
||||
+28
-1
@@ -8,7 +8,7 @@ from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
|
||||
|
||||
from app.models import NodeStatus, TaskStatus, UserRole, WithdrawalStatus, YieldStatus
|
||||
from app.models import 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:_-]+$")]
|
||||
@@ -86,10 +86,13 @@ class NodeView(NodeOut):
|
||||
wallet_address: str
|
||||
online_rate: MoneyText = Decimal("0")
|
||||
today_points: MoneyText = Decimal("0")
|
||||
node_token: str | None = None
|
||||
activated: bool = False
|
||||
|
||||
|
||||
class HeartbeatIn(APIModel):
|
||||
sn: SN
|
||||
token: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class HeartbeatOut(APIModel):
|
||||
@@ -113,6 +116,7 @@ class TaskReportIn(APIModel):
|
||||
task_id: str = Field(min_length=36, max_length=36)
|
||||
success: bool
|
||||
nonce: str = Field(min_length=8, max_length=128)
|
||||
token: str | None = Field(default=None, max_length=128)
|
||||
|
||||
|
||||
class ContributionOut(APIModel):
|
||||
@@ -180,6 +184,26 @@ class MeNodeCreate(APIModel):
|
||||
callback_url: str | None = None
|
||||
|
||||
|
||||
class OrderCreate(APIModel):
|
||||
tx_id: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
|
||||
|
||||
|
||||
class OrderOut(APIModel):
|
||||
id: int
|
||||
user_id: int
|
||||
amount: MoneyText
|
||||
asset_symbol: str
|
||||
status: OrderStatus
|
||||
tx_id: str
|
||||
created_at: datetime
|
||||
reviewed_at: datetime | None = None
|
||||
sn: str | None = None
|
||||
|
||||
|
||||
class OrderView(OrderOut):
|
||||
wallet_address: str
|
||||
|
||||
|
||||
class WithdrawalConfirm(APIModel):
|
||||
tx_hash: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
|
||||
|
||||
@@ -227,5 +251,8 @@ class DashboardOut(APIModel):
|
||||
available_points: MoneyText
|
||||
settled_points: MoneyText
|
||||
today_points: MoneyText
|
||||
license_price: MoneyText
|
||||
license_asset: str
|
||||
nodes: list[NodeView]
|
||||
orders: list[OrderOut]
|
||||
withdrawals: list[WithdrawalOut]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -14,10 +15,20 @@ from app.models import ComputeNode, NodeStatus, UptimeInterval
|
||||
from app.services.common import get_node_by_sn
|
||||
|
||||
|
||||
def record_heartbeat(session: Session, sn: str, now: datetime | None = None) -> ComputeNode:
|
||||
def assert_node_token(node: ComputeNode, token: str | None) -> None:
|
||||
"""旧节点没有 Token 时仍按序列号接入。审核开通的节点必须带上专属 Token。"""
|
||||
if not node.node_token:
|
||||
return
|
||||
supplied = (token or "").strip()
|
||||
if not supplied or not hmac.compare_digest(node.node_token, supplied):
|
||||
raise AppError("节点 Token 不正确", 403)
|
||||
|
||||
|
||||
def record_heartbeat(session: Session, sn: str, token: str | None = None, now: datetime | 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)
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""节点授权订单。付款凭证先入库,管理员通过后才开通节点。"""
|
||||
|
||||
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())
|
||||
|
||||
|
||||
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,不要发给别人。
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
CONFIG = json.loads({config!r})
|
||||
|
||||
|
||||
def beat():
|
||||
body = json.dumps({{"sn": CONFIG["sn"], "token": CONFIG["token"]}}).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:
|
||||
print(response.read().decode())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
while True:
|
||||
try:
|
||||
beat()
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
print(exc)
|
||||
time.sleep(60)
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
@@ -63,6 +63,8 @@ def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
|
||||
"status": node.status,
|
||||
"last_heartbeat_at": node.last_heartbeat_at,
|
||||
"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"),
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ def register_node(
|
||||
sn: str,
|
||||
purchase_amount: Decimal,
|
||||
callback_url: str | None = None,
|
||||
node_token: str | None = None,
|
||||
) -> ComputeNode:
|
||||
user = get_user_by_wallet(session, wallet_address.strip())
|
||||
serial = sn.strip()
|
||||
@@ -67,6 +68,7 @@ def register_node(
|
||||
license_expires_at=add_months(started, settings.license_months),
|
||||
status=NodeStatus.OFFLINE,
|
||||
callback_url=callback,
|
||||
node_token=node_token,
|
||||
created_at=started,
|
||||
updated_at=started,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user