From f329f647c7f15de2e454d4abfbbafc6419137daf Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 10 Oct 2026 23:04:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=A2=E5=8D=95=E9=9C=80=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E5=AE=A1=E6=A0=B8=E5=90=8E=E6=89=8D=E5=BC=80=E9=80=9A?= =?UTF-8?q?=E8=8A=82=E7=82=B9=EF=BC=8C=E5=B9=B6=E6=9B=B4=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=A1=B5=E6=96=87=E6=A1=88=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app/api.py | 73 +++++++++++++++-- app/config.py | 2 + app/models.py | 33 ++++++++ app/schema_upgrade.py | 31 ++++--- app/schemas.py | 29 ++++++- app/services/heartbeat.py | 13 ++- app/services/orders.py | 117 +++++++++++++++++++++++++++ app/services/portal.py | 2 + app/services/registry.py | 2 + frontend/src/api.js | 24 ++++++ frontend/src/format.js | 1 + frontend/src/styles.css | 40 ++++++++- frontend/src/views/AdminOverview.vue | 57 ++++++++++++- frontend/src/views/Dashboard.vue | 65 ++++++++++----- frontend/src/views/Login.vue | 43 +++++++++- tests/test_core_flow.py | 57 +++++++++++++ web/assets/index-CZ0L8T-X.js | 26 ------ web/assets/index-D6FtGpwv.js | 26 ++++++ web/assets/index-kLDEr4vr.css | 1 + web/assets/index-mWM9aOGS.css | 1 - web/index.html | 4 +- 说明.md | 17 ++-- 22 files changed, 583 insertions(+), 81 deletions(-) create mode 100644 app/services/orders.py delete mode 100644 web/assets/index-CZ0L8T-X.js create mode 100644 web/assets/index-D6FtGpwv.js create mode 100644 web/assets/index-kLDEr4vr.css delete mode 100644 web/assets/index-mWM9aOGS.css diff --git a/app/api.py b/app/api.py index 10e6e80..3431381 100644 --- a/app/api.py +++ b/app/api.py @@ -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(), diff --git a/app/config.py b/app/config.py index 0d5b042..f3e8b70 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/models.py b/app/models.py index 6bef6dd..da4a6f2 100644 --- a/app/models.py +++ b/app/models.py @@ -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 密钥。""" diff --git a/app/schema_upgrade.py b/app/schema_upgrade.py index 8587011..b483e67 100644 --- a/app/schema_upgrade.py +++ b/app/schema_upgrade.py @@ -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)")) diff --git a/app/schemas.py b/app/schemas.py index f0c9caf..76753f0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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] diff --git a/app/services/heartbeat.py b/app/services/heartbeat.py index b7f6fc1..497ccdf 100644 --- a/app/services/heartbeat.py +++ b/app/services/heartbeat.py @@ -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) diff --git a/app/services/orders.py b/app/services/orders.py new file mode 100644 index 0000000..dd02d62 --- /dev/null +++ b/app/services/orders.py @@ -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 diff --git a/app/services/portal.py b/app/services/portal.py index 9da4438..e077940 100644 --- a/app/services/portal.py +++ b/app/services/portal.py @@ -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"), } diff --git a/app/services/registry.py b/app/services/registry.py index 94e2001..4d49160 100644 --- a/app/services/registry.py +++ b/app/services/registry.py @@ -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, ) diff --git a/frontend/src/api.js b/frontend/src/api.js index e5a1190..9474336 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -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; diff --git a/frontend/src/format.js b/frontend/src/format.js index 84db48e..64b3581 100644 --- a/frontend/src/format.js +++ b/frontend/src/format.js @@ -27,6 +27,7 @@ export const statusText = { online: "在线", offline: "离线", pending: "待审核", + approved: "已开通", completed: "已打款", rejected: "已拒绝", failed: "失败", diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4570a5c..aca8258 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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%; diff --git a/frontend/src/views/AdminOverview.vue b/frontend/src/views/AdminOverview.vue index c5194cf..333f39a 100644 --- a/frontend/src/views/AdminOverview.vue +++ b/frontend/src/views/AdminOverview.vue @@ -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)); +

授权订单

+
+ + + + + + + + + + + + + + + + + +
单号钱包金额TxID状态节点
#{{ row.id }}{{ row.wallet_address }}{{ money(row.amount) }} {{ row.asset_symbol }}{{ row.tx_id }}{{ statusText[row.status] }}{{ row.sn || "—" }} + + +
还没有授权订单。
+
+

节点监控

diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 279178c..658fc3b 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,13 +1,13 @@ - + +
diff --git a/说明.md b/说明.md index d46a13f..80f6003 100644 --- a/说明.md +++ b/说明.md @@ -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` | 令牌有效分钟数 |