订单需管理员审核后才开通节点,并更新登录页文案。
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,
|
||||
)
|
||||
|
||||
@@ -37,6 +37,30 @@ export async function api(path, options = {}) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function download(path, filename) {
|
||||
const headers = {};
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(`/api/v1${path}`, { headers });
|
||||
if (response.status === 401) {
|
||||
clearSession();
|
||||
if (location.pathname !== "/login") location.assign("/login");
|
||||
throw new Error("登录已失效");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
throw new Error(messageOf(data) || "下载失败");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function messageOf(data) {
|
||||
if (!data) return "";
|
||||
if (typeof data.detail === "string") return data.detail;
|
||||
|
||||
@@ -27,6 +27,7 @@ export const statusText = {
|
||||
online: "在线",
|
||||
offline: "离线",
|
||||
pending: "待审核",
|
||||
approved: "已开通",
|
||||
completed: "已打款",
|
||||
rejected: "已拒绝",
|
||||
failed: "失败",
|
||||
|
||||
+36
-4
@@ -49,13 +49,43 @@ a { color: inherit; }
|
||||
}
|
||||
.gate-copy h1 {
|
||||
margin: 12px 0 18px;
|
||||
max-width: 11em;
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(52px, 6vw, 88px);
|
||||
font-size: clamp(40px, 4vw, 64px);
|
||||
font-weight: 560;
|
||||
letter-spacing: -0.04em;
|
||||
line-height: 0.95;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.gate-copy p:last-child { max-width: 420px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||
.gate-copy p:last-child { max-width: 460px; color: var(--muted); font-size: 18px; line-height: 1.6; }
|
||||
.agreement-link {
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--gold);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.agreement-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 28px;
|
||||
background: rgba(5, 6, 8, 0.72);
|
||||
}
|
||||
.agreement-sheet {
|
||||
width: min(720px, 100%);
|
||||
max-height: min(82vh, 860px);
|
||||
overflow: auto;
|
||||
}
|
||||
.agreement-sheet h3 {
|
||||
margin-top: 18px;
|
||||
font-family: var(--serif);
|
||||
font-size: 20px;
|
||||
font-weight: 560;
|
||||
}
|
||||
.agreement-sheet p { line-height: 1.7; }
|
||||
.eyebrow {
|
||||
color: var(--gold);
|
||||
letter-spacing: 0.18em;
|
||||
@@ -87,7 +117,9 @@ a { color: inherit; }
|
||||
font-weight: 700;
|
||||
}
|
||||
.gold.wide { width: 100%; margin-top: 4px; }
|
||||
.gold:disabled { opacity: 0.6; }
|
||||
.gold:disabled, .texty:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
.token-box { font-family: var(--mono); font-size: 12px; }
|
||||
.plate.locked { border-style: dashed; }
|
||||
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
|
||||
input {
|
||||
width: 100%;
|
||||
|
||||
@@ -8,22 +8,52 @@ const summary = ref(null);
|
||||
const users = ref([]);
|
||||
const nodes = ref([]);
|
||||
const withdrawals = ref([]);
|
||||
const orders = ref([]);
|
||||
const hashes = ref({});
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
let timer = 0;
|
||||
|
||||
async function load() {
|
||||
const [nextSummary, nextUsers, nextNodes, nextCash] = await Promise.all([
|
||||
const [nextSummary, nextUsers, nextNodes, nextCash, nextOrders] = await Promise.all([
|
||||
api("/admin/summary"),
|
||||
api("/admin/users"),
|
||||
api("/admin/nodes"),
|
||||
api("/admin/withdrawals"),
|
||||
api("/admin/orders"),
|
||||
]);
|
||||
summary.value = nextSummary;
|
||||
users.value = nextUsers;
|
||||
nodes.value = nextNodes;
|
||||
withdrawals.value = nextCash;
|
||||
orders.value = nextOrders;
|
||||
}
|
||||
|
||||
async function approveOrder(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
const result = await api(`/admin/orders/${row.id}/approve`, { method: "POST" });
|
||||
notice.value = `订单 #${row.id} 已开通,节点 ${result.sn}`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectOrder(row) {
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await api(`/admin/orders/${row.id}/reject`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ note: "支付凭证未通过" }),
|
||||
});
|
||||
notice.value = `订单 #${row.id} 已拒绝`;
|
||||
await load();
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm(row) {
|
||||
@@ -104,6 +134,31 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<h2 class="section-title">授权订单</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>单号</th><th>钱包</th><th>金额</th><th>TxID</th><th>状态</th><th>节点</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in orders" :key="row.id">
|
||||
<td>#{{ row.id }}</td>
|
||||
<td class="mono">{{ row.wallet_address }}</td>
|
||||
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
|
||||
<td class="mono">{{ row.tx_id }}</td>
|
||||
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
|
||||
<td>{{ row.sn || "—" }}</td>
|
||||
<td class="actions" v-if="row.status === 'pending'">
|
||||
<button class="gold" type="button" @click="approveOrder(row)">通过</button>
|
||||
<button class="texty" type="button" @click="rejectOrder(row)">拒绝</button>
|
||||
</td>
|
||||
<td v-else></td>
|
||||
</tr>
|
||||
<tr v-if="!orders.length"><td colspan="7" class="empty">还没有授权订单。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">节点监控</h2>
|
||||
<section class="board">
|
||||
<article v-for="node in nodes" :key="node.sn" class="plate">
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import Shell from "../components/Shell.vue";
|
||||
import { api } from "../api";
|
||||
import { api, download } from "../api";
|
||||
import { money, pct, statusText, when } from "../format";
|
||||
|
||||
const board = ref(null);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const nodeForm = ref({ sn: "", purchase_amount: "", callback_url: "" });
|
||||
const txId = ref("");
|
||||
const cashForm = ref({ amount: "", asset_symbol: "POINT" });
|
||||
let timer = 0;
|
||||
|
||||
@@ -27,16 +27,25 @@ async function run(work, done) {
|
||||
}
|
||||
}
|
||||
|
||||
function bindNode() {
|
||||
const body = {
|
||||
sn: nodeForm.value.sn.trim(),
|
||||
purchase_amount: nodeForm.value.purchase_amount,
|
||||
};
|
||||
if (nodeForm.value.callback_url.trim()) body.callback_url = nodeForm.value.callback_url.trim();
|
||||
function placeOrder() {
|
||||
return run(async () => {
|
||||
await api("/me/nodes", { method: "POST", body: JSON.stringify(body) });
|
||||
nodeForm.value = { sn: "", purchase_amount: "", callback_url: "" };
|
||||
}, "节点已登记");
|
||||
await api("/me/orders", { method: "POST", body: JSON.stringify({ tx_id: txId.value.trim() }) });
|
||||
txId.value = "";
|
||||
}, "支付凭证已提交,等待审核");
|
||||
}
|
||||
|
||||
async function copyToken(token) {
|
||||
error.value = "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(token);
|
||||
notice.value = "Token 已复制";
|
||||
} catch {
|
||||
error.value = "复制失败,请手动选择 Token";
|
||||
}
|
||||
}
|
||||
|
||||
function downloadClient(node) {
|
||||
return run(() => download(`/me/nodes/${encodeURIComponent(node.sn)}/client`, `hypernode-${node.sn}.py`), "客户端已开始下载");
|
||||
}
|
||||
|
||||
function requestCash() {
|
||||
@@ -91,10 +100,23 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
</section>
|
||||
|
||||
<section class="board">
|
||||
<article v-for="order in board?.orders || []" :key="order.id" class="plate" :class="{ locked: order.status !== 'approved' }">
|
||||
<header>
|
||||
<strong>订单 #{{ order.id }}</strong>
|
||||
<span class="pill" :class="order.status"><i />{{ statusText[order.status] }}</span>
|
||||
</header>
|
||||
<p class="wallet">{{ money(order.amount) }} {{ order.asset_symbol }} · {{ order.tx_id }}</p>
|
||||
<p v-if="order.status === 'pending'" class="sub">审核通过前不能复制 Token,也不能下载客户端。</p>
|
||||
<p v-else-if="order.sn" class="sub">节点 {{ order.sn }}</p>
|
||||
<div v-if="order.status !== 'approved'" class="actions">
|
||||
<button class="gold" type="button" disabled>复制 Token</button>
|
||||
<button class="texty" type="button" disabled>下载客户端</button>
|
||||
</div>
|
||||
</article>
|
||||
<article v-for="node in board?.nodes || []" :key="node.sn" class="plate">
|
||||
<header>
|
||||
<strong>{{ node.sn }}</strong>
|
||||
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
|
||||
<span class="pill" :class="node.activated ? node.status : 'pending'"><i />{{ node.activated ? statusText[node.status] : "待审核" }}</span>
|
||||
</header>
|
||||
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
|
||||
<dl>
|
||||
@@ -102,18 +124,21 @@ onUnmounted(() => window.clearInterval(timer));
|
||||
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
|
||||
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
|
||||
</dl>
|
||||
<label>挂机 Token<input class="token-box" :value="node.activated ? node.node_token : '审核通过后发放'" readonly /></label>
|
||||
<div class="actions">
|
||||
<button class="gold" type="button" :disabled="!node.activated" @click="copyToken(node.node_token)">复制 Token</button>
|
||||
<button class="texty" type="button" :disabled="!node.activated" @click="downloadClient(node)">下载客户端</button>
|
||||
</div>
|
||||
</article>
|
||||
<p v-if="board && !board.nodes.length" class="empty">还没有节点。下面可以登记第一台。</p>
|
||||
<p v-if="board && !board.nodes.length && !board.orders.length" class="empty">还没有节点。下面可以购买授权。</p>
|
||||
</section>
|
||||
|
||||
<section class="forms">
|
||||
<form class="panel" @submit.prevent="bindNode">
|
||||
<h2>登记节点</h2>
|
||||
<p class="sub">绑到当前钱包,授权周期 12 个月。</p>
|
||||
<label>序列号<input v-model="nodeForm.sn" required minlength="4" placeholder="NODE-001" /></label>
|
||||
<label>购买金额<input v-model="nodeForm.purchase_amount" required placeholder="365" /></label>
|
||||
<label>回调地址<input v-model="nodeForm.callback_url" placeholder="https:// 可选" /></label>
|
||||
<button class="gold" type="submit">登记</button>
|
||||
<form class="panel" @submit.prevent="placeOrder">
|
||||
<h2>购买节点授权</h2>
|
||||
<p class="sub">{{ board ? money(board.license_price) : "1,500" }} {{ board?.license_asset || "USDT" }}。提交链上 TxID 后进入待审核,通过后开通 12 个月并签发 Token。</p>
|
||||
<label>TxID<input v-model="txId" required minlength="6" placeholder="链上交易哈希" /></label>
|
||||
<button class="gold" type="submit">提交支付凭证</button>
|
||||
</form>
|
||||
<form class="panel" @submit.prevent="requestCash">
|
||||
<h2>申请提现</h2>
|
||||
|
||||
@@ -14,6 +14,7 @@ const form = ref({
|
||||
password: "",
|
||||
confirm: "",
|
||||
});
|
||||
const termsOpen = ref(false);
|
||||
|
||||
async function submit() {
|
||||
error.value = "";
|
||||
@@ -65,8 +66,8 @@ function register() {
|
||||
<div class="gate">
|
||||
<section class="gate-copy">
|
||||
<p class="eyebrow">HyperNode</p>
|
||||
<h1>算力在这里入账。</h1>
|
||||
<p>钱包或账号进入自己的节点。管理员从这里审核提现、看全网在线。</p>
|
||||
<h1>连接全球 DePIN 分布式算力网络</h1>
|
||||
<p>基于去中心化节点的推理验证与任务调度平台。连接您的算力节点,实时监控质押释放与算力收益。</p>
|
||||
</section>
|
||||
<form class="gate-card" @submit.prevent="submit">
|
||||
<div class="switch">
|
||||
@@ -80,7 +81,7 @@ function register() {
|
||||
|
||||
<label v-if="panel === 'register' || method === 'wallet'">
|
||||
钱包地址
|
||||
<input v-model="form.wallet_address" required minlength="4" placeholder="0xabc123" autocomplete="username" />
|
||||
<input v-model="form.wallet_address" required minlength="4" placeholder="请填写您的 USDT (TRC20/ERC20) 收益接收地址" autocomplete="username" />
|
||||
</label>
|
||||
<label v-if="panel === 'register' || method === 'account'">
|
||||
用户名
|
||||
@@ -96,7 +97,41 @@ function register() {
|
||||
</label>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<button class="gold wide" type="submit" :disabled="pending">{{ pending ? "请稍候" : panel === "login" ? "进入" : "创建账号" }}</button>
|
||||
<p class="hint">管理员不能只用钱包进入,需要账号密码。</p>
|
||||
<p class="hint">
|
||||
注册即代表您已阅读并同意
|
||||
<button type="button" class="agreement-link" @click="termsOpen = true">《算力节点质押与服务协议》</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div v-if="termsOpen" class="agreement-mask" @click.self="termsOpen = false">
|
||||
<article class="panel agreement-sheet">
|
||||
<p class="eyebrow">HyperNode</p>
|
||||
<h2>算力节点质押与服务协议</h2>
|
||||
<p class="sub">生效日期:2026年10月 · 协议版本:v1.2</p>
|
||||
<p>欢迎您申请参与 HyperNode 去中心化 AI 算力网络(以下简称“本网络”或“平台”)。在您勾选“同意协议”或完成 USDT 质押激活节点之前,请务必仔细阅读本协议的所有条款。</p>
|
||||
<h3>一、定义与说明</h3>
|
||||
<p>算力节点(Compute Node):指由用户运行或绑定的、通过轻量级客户端接入 HyperNode 网络的边缘计算节点。</p>
|
||||
<p>节点履约质押金(Collateral Stake):指用户为保障节点运行质量(SLA)、防止网络作弊或恶意掉线,向平台系统质押的 1500 USDT 保证金。</p>
|
||||
<p>节点 Token(Node Token):平台下发给已激活节点的专属加密凭证,用于客户端与网络服务端的鉴权与通信。</p>
|
||||
<p>算力积分与收益(Yield / Points):指节点通过维持在线、响应网络数据推理验证与任务调度所获得的贡献奖励。</p>
|
||||
<h3>二、质押与激活规则</h3>
|
||||
<p>门槛与额度:激活单个标准 AI 算力节点需提交 1500 USDT 作为履约质押金。</p>
|
||||
<p>审核与开通:用户提交链上交易哈希(TxID)后,需经系统及管理员清算审核。审核通过后,节点正式进入 12 个月的授权运行周期,并解锁客户端下载及 Node Token 绑定权限。</p>
|
||||
<p>质押金优先抵扣与赎回:节点每日产生的算力收益,将优先计入“质押金赎回额度”。当累计释放收益达到 1500 USDT(即本金全额解锁)后,后续产生的收益自动转为“网络算力激励”。</p>
|
||||
<h3>三、节点运行规范与 SLA 服务质量考核</h3>
|
||||
<p>在线率要求:节点需保持每天不少于规定时长的在线状态,并正常响应服务端的随机任务下发与心跳检测。</p>
|
||||
<p>收益衰减与扣罚机制:每日算力收益由“网络在线率”与“任务响应率”共同决定。若节点频繁离线、断网或拒绝响应计算任务,当天的表现系数将自动降低,导致当日算力收益缩减或清零,质押金赎回周期将相应顺延。</p>
|
||||
<p>全网算力难度调整:平台根据全网总激活节点数量与每日总奖池,动态调整分配系数。节点收益受全网总算力竞争影响。</p>
|
||||
<h3>四、提现与清算机制</h3>
|
||||
<p>提现申请:用户可在控制台申请将已解冻的积分/USDT 提现至指定的个人加密钱包地址(TRC20 / ERC20)。</p>
|
||||
<p>人工审核与安全排查:为防范 Sybil 攻击(女巫攻击)与自动化作弊,所有提现申请需经过平台安全清算与管理员审核。平台有权在发现异常流量或欺诈挂机时冻结相关节点。</p>
|
||||
<h3>五、免责声明与风险提示</h3>
|
||||
<p>网络与硬件风险:用户需自行承担因个人宽带中断、设备损坏、电力故障或客户端被防火墙拦截导致的节点掉线损失。</p>
|
||||
<p>区块链与资产风险:请务必确认填写的 USDT 接收地址准确无误。因用户个人地址填写错误导致的资产损失,平台概不负责。</p>
|
||||
<p>协议修改:平台保留根据网络发展与技术升级对本协议条款、产出算法进行微调的权利,更新后的条款将于控制台公示后生效。</p>
|
||||
<p>点击“同意”或继续提交质押申请,即代表您已完全理解并接受上述所有条款与风险提示。</p>
|
||||
<button class="gold" type="button" @click="termsOpen = false">同意</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -295,6 +295,63 @@ class CoreFlowTest(unittest.TestCase):
|
||||
wallet_admin = self.client.post("/api/v1/auth/login", json={"wallet_address": "admin-root"})
|
||||
self.assertEqual(wallet_admin.status_code, 403, wallet_admin.text)
|
||||
|
||||
def test_order_stays_locked_until_admin_approves(self) -> None:
|
||||
registered = self.client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"wallet_address": "0xbuyer01", "username": "buyer", "password": "secret1"},
|
||||
)
|
||||
self.assertEqual(registered.status_code, 200, registered.text)
|
||||
headers = {"Authorization": f"Bearer {registered.json()['access_token']}"}
|
||||
|
||||
created = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"})
|
||||
self.assertEqual(created.status_code, 200, created.text)
|
||||
self.assertEqual(created.json()["status"], "pending")
|
||||
self.assertEqual(created.json()["amount"], "1500.00000000")
|
||||
order_id = created.json()["id"]
|
||||
|
||||
denied = self.client.post(f"/api/v1/admin/orders/{order_id}/approve", headers=headers)
|
||||
self.assertEqual(denied.status_code, 403, denied.text)
|
||||
board = self.client.get("/api/v1/me/dashboard", headers=headers)
|
||||
self.assertEqual(board.status_code, 200, board.text)
|
||||
self.assertEqual(board.json()["orders"][0]["status"], "pending")
|
||||
self.assertEqual(board.json()["nodes"], [])
|
||||
|
||||
approved = self.client.post(f"/api/v1/admin/orders/{order_id}/approve", headers=self._admin_headers())
|
||||
self.assertEqual(approved.status_code, 200, approved.text)
|
||||
self.assertEqual(approved.json()["status"], "approved")
|
||||
sn = approved.json()["sn"]
|
||||
self.assertTrue(sn)
|
||||
|
||||
board = self.client.get("/api/v1/me/dashboard", headers=headers)
|
||||
node = board.json()["nodes"][0]
|
||||
self.assertEqual(node["sn"], sn)
|
||||
self.assertTrue(node["activated"])
|
||||
self.assertTrue(node["node_token"])
|
||||
self.assertEqual(node["license_months"], 12)
|
||||
|
||||
missing = self.client.post("/api/v1/node/heartbeat", json={"sn": sn})
|
||||
self.assertEqual(missing.status_code, 403, missing.text)
|
||||
beat = self.client.post("/api/v1/node/heartbeat", json={"sn": sn, "token": node["node_token"]})
|
||||
self.assertEqual(beat.status_code, 200, beat.text)
|
||||
self.assertEqual(beat.json()["status"], "online")
|
||||
|
||||
client = self.client.get(f"/api/v1/me/nodes/{sn}/client", headers=headers)
|
||||
self.assertEqual(client.status_code, 200, client.text)
|
||||
self.assertIn(node["node_token"], client.text)
|
||||
|
||||
duplicated = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"})
|
||||
self.assertEqual(duplicated.status_code, 409, duplicated.text)
|
||||
rejected_order = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytxrejected01"})
|
||||
self.assertEqual(rejected_order.status_code, 200, rejected_order.text)
|
||||
rejected = self.client.post(
|
||||
f"/api/v1/admin/orders/{rejected_order.json()['id']}/reject",
|
||||
headers=self._admin_headers(),
|
||||
json={"note": "凭证无效"},
|
||||
)
|
||||
self.assertEqual(rejected.status_code, 200, rejected.text)
|
||||
self.assertEqual(rejected.json()["status"], "rejected")
|
||||
self.assertIsNone(rejected.json()["sn"])
|
||||
|
||||
def _auth(self, wallet: str) -> dict[str, str]:
|
||||
logged = self.client.post("/api/v1/auth/login", json={"wallet_address": wallet})
|
||||
self.assertEqual(logged.status_code, 200, logged.text)
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,8 +7,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,560;9..144,650&family=IBM+Plex+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<script type="module" crossorigin src="/assets/index-CZ0L8T-X.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-mWM9aOGS.css">
|
||||
<script type="module" crossorigin src="/assets/index-D6FtGpwv.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-kLDEr4vr.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -80,8 +80,8 @@ journalctl -u hypernode.service | grep 初始密码
|
||||
页面路由:
|
||||
|
||||
- `/login` 登录和注册
|
||||
- `/dashboard` 用户自己的节点、今日预计积分、提现申请
|
||||
- `/admin` 管理员看总用户、总节点、总产出、节点在线率和提现审核
|
||||
- `/dashboard` 用户提交 1500 USDT 支付凭证、查看待审核订单、复制已开通节点的 Token 并下载客户端
|
||||
- `/admin` 管理员审核授权订单,通过后自动开通 12 个月节点;同时看总用户、总节点、总产出和提现
|
||||
|
||||
节点心跳、拉任务、回传任务不需要登录。查看全网数据、清算、确认提现需要管理员令牌。
|
||||
|
||||
@@ -93,8 +93,13 @@ journalctl -u hypernode.service | grep 初始密码
|
||||
|---|---|---|
|
||||
| POST | `/auth/register` | 注册钱包,可带用户名和密码,返回令牌 |
|
||||
| POST | `/auth/login` | 钱包地址,或用户名加密码 |
|
||||
| GET | `/me/dashboard` | 当前用户的节点、积分和提现 |
|
||||
| POST | `/me/nodes` | 给当前钱包登记节点 |
|
||||
| GET | `/me/dashboard` | 当前用户的节点、订单、积分和提现 |
|
||||
| POST | `/me/orders` | 提交 1500 USDT 支付凭证 TxID,订单进入待审核 |
|
||||
| GET | `/me/nodes/{sn}/client` | 下载挂机客户端。节点未开通时返回 403 |
|
||||
| GET | `/admin/orders` | 管理员:授权订单,待审核排在前面 |
|
||||
| POST | `/admin/orders/{id}/approve` | 管理员:通过订单,创建节点并签发 Token |
|
||||
| POST | `/admin/orders/{id}/reject` | 管理员:拒绝订单,不创建节点 |
|
||||
| POST | `/me/nodes` | 直接登记节点,不签发挂机 Token |
|
||||
| POST | `/me/withdrawals` | 当前用户申请提现 |
|
||||
| GET | `/admin/summary` | 管理员:用户数、节点数、总产出 |
|
||||
| GET | `/admin/users` | 管理员:全网用户 |
|
||||
@@ -105,7 +110,7 @@ journalctl -u hypernode.service | grep 初始密码
|
||||
| POST | `/users` | 只登记钱包,不设置密码 |
|
||||
| POST | `/nodes` | 登记节点,必须是该钱包本人或管理员 |
|
||||
| GET | `/nodes/{sn}` | 查看节点 |
|
||||
| POST | `/node/heartbeat` | 心跳,请求体 `{"sn":"..."}` |
|
||||
| POST | `/node/heartbeat` | 心跳。已开通节点必须额外带 `token` |
|
||||
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
|
||||
| POST | `/node/task/report` | 回传 `sn`、`task_id`、`success`、`nonce` |
|
||||
| GET | `/nodes/{sn}/contribution?yield_date=YYYY-MM-DD` | 预览某日产出,不入账 |
|
||||
@@ -136,6 +141,8 @@ journalctl -u hypernode.service | grep 初始密码
|
||||
| `YIELD_TIMEZONE` | `Asia/Shanghai` | 清算触发时区 |
|
||||
| `YIELD_HOUR` / `YIELD_MINUTE` | `0` / `5` | 清算时刻 |
|
||||
| `LICENSE_MONTHS` | `12` | 新节点授权月数 |
|
||||
| `LICENSE_PRICE` | `1500` | 购买节点授权的金额 |
|
||||
| `LICENSE_ASSET` | `USDT` | 购买节点授权的资产符号 |
|
||||
| `DISABLE_SCHEDULER` | `false` | 设为 `true` 时不启动定时任务 |
|
||||
| `JWT_SECRET` | 空则自动生成并写入数据库 | 登录令牌签名 |
|
||||
| `JWT_EXPIRE_MINUTES` | `1440` | 令牌有效分钟数 |
|
||||
|
||||
Reference in New Issue
Block a user