订单需管理员审核后才开通节点,并更新登录页文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 23:04:24 +08:00
parent 8b55b9cf55
commit f329f647c7
22 changed files with 583 additions and 81 deletions
+68 -5
View File
@@ -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(),
+2
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+12 -1
View File
@@ -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)
+117
View File
@@ -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
+2
View File
@@ -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"),
}
+2
View File
@@ -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,
)