加上登录和角色权限,并用 Vue 控制台区分用户与管理员。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-10 22:43:31 +08:00
parent 78601aae5e
commit 8b55b9cf55
36 changed files with 2947 additions and 1007 deletions
+1
View File
@@ -5,3 +5,4 @@ venv/
.env
*.db
.pytest_cache/
node_modules/
+171 -60
View File
@@ -5,25 +5,34 @@ from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy import func, select
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.database import get_db
from app.models import ComputeNode, ComputeTask, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
from app.money import money, to_decimal
from app.deps import AdminUser, CurrentUser
from app.errors import AppError
from app.models import ComputeNode, ComputeTask, User, UserRole, WithdrawalLog, WithdrawalStatus
from app.money import money
from app.schemas import (
AdminUserOut,
BalanceOut,
ContributionOut,
DashboardOut,
HeartbeatIn,
HeartbeatOut,
LoginIn,
MeNodeCreate,
MeWithdrawalCreate,
NodeCreate,
NodeOut,
NodeView,
RegisterIn,
SettleOut,
SummaryOut,
TaskOut,
TaskReportIn,
TokenOut,
UserCreate,
UserOut,
WithdrawalConfirm,
@@ -33,8 +42,18 @@ from app.schemas import (
WithdrawalView,
YieldOut,
)
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.portal import (
authorized_amount,
live_nodes,
node_counts,
pending_withdrawal_amount,
settled_points,
user_rows,
)
from app.services.registry import register_node, register_user
from app.services.tasks import list_pending_tasks, report_task
from app.services.withdrawals import available_balance, confirm_withdrawal, reject_withdrawal, request_withdrawal
@@ -44,35 +63,90 @@ router = APIRouter(prefix="/api/v1")
Db = Annotated[Session, Depends(get_db)]
@router.get("/summary", response_model=SummaryOut)
def summary(db: Db) -> SummaryOut:
users = db.scalar(select(func.count()).select_from(User)) or 0
nodes = db.scalar(select(func.count()).select_from(ComputeNode)) or 0
online = db.scalar(
select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE)
) or 0
authorized = _sum(db.scalars(select(ComputeNode.purchase_amount)).all())
settled = _sum(
db.scalars(select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)).all()
)
pending = _sum(
db.scalars(
select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING)
@router.post("/auth/register", response_model=TokenOut)
def register(body: RegisterIn, db: Db) -> TokenOut:
user = register_account(db, body.wallet_address, body.username, body.password)
return _token(user)
@router.post("/auth/login", response_model=TokenOut)
def login(body: LoginIn, db: Db) -> TokenOut:
user = authenticate(db, body.wallet_address, body.username, body.password)
return _token(user)
@router.get("/me", response_model=UserOut)
def me(user: CurrentUser) -> User:
return user
@router.get("/me/dashboard", response_model=DashboardOut)
def my_dashboard(user: CurrentUser, db: Db) -> DashboardOut:
nodes = live_nodes(db, user.id)
withdrawals = db.scalars(
select(WithdrawalLog).where(WithdrawalLog.user_id == user.id).order_by(WithdrawalLog.id.desc()).limit(20)
).all()
today = money(sum((item["today_points"] for item in nodes), Decimal("0")))
return DashboardOut(
wallet_address=user.wallet_address,
username=user.username,
role=user.role,
available_points=available_balance(db, user.id),
settled_points=settled_points(db, user.id),
today_points=today,
nodes=[NodeView.model_validate(item) for item in nodes],
withdrawals=[WithdrawalOut.model_validate(item) for item in withdrawals],
)
return SummaryOut(
users=users,
nodes=nodes,
online=online,
offline=nodes - online,
authorized_amount=authorized,
settled_points=settled,
pending_withdrawals=pending,
)
@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)
@router.post("/me/withdrawals", response_model=WithdrawalOut)
def create_my_withdrawal(body: MeWithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
row, remaining = request_withdrawal(db, user.wallet_address, body.amount, body.asset_symbol)
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
@router.get("/admin/summary", response_model=SummaryOut)
def admin_summary(_: AdminUser, db: Db) -> SummaryOut:
return _summary(db)
@router.get("/admin/users", response_model=list[AdminUserOut])
def admin_users(_: AdminUser, db: Db) -> list[dict]:
return user_rows(db)
@router.get("/admin/nodes", response_model=list[NodeView])
def admin_nodes(_: AdminUser, db: Db) -> list[NodeView]:
return [NodeView.model_validate(item) for item in live_nodes(db)]
@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)]
@router.post("/admin/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
def admin_confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
@router.post("/admin/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
def admin_reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
return reject_withdrawal(db, withdrawal_id, body.note)
@router.get("/summary", response_model=SummaryOut)
def summary(_: AdminUser, db: Db) -> SummaryOut:
return _summary(db)
@router.get("/users", response_model=list[UserOut])
def list_users(db: Db) -> list[User]:
def list_users(_: AdminUser, db: Db) -> list[User]:
return list(db.scalars(select(User).order_by(User.id.desc())).all())
@@ -82,21 +156,19 @@ def create_user(body: UserCreate, db: Db) -> UserOut:
@router.get("/nodes", response_model=list[NodeView])
def list_nodes(db: Db) -> list[NodeView]:
rows = db.scalars(
select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc())
).all()
return [_node_view(row) for row in rows]
def list_nodes(_: AdminUser, db: Db) -> list[NodeView]:
return [NodeView.model_validate(item) for item in live_nodes(db)]
@router.post("/nodes", response_model=NodeOut)
def create_node(body: NodeCreate, db: Db) -> NodeOut:
def create_node(body: NodeCreate, user: CurrentUser, db: Db) -> NodeOut:
_must_own_wallet(user, body.wallet_address)
return register_node(db, body.wallet_address, body.sn, body.purchase_amount, body.callback_url)
@router.get("/nodes/{sn}", response_model=NodeOut)
def get_node(sn: str, db: Db) -> NodeOut:
return get_node_by_sn(db, sn)
def get_node(sn: str, user: CurrentUser, db: Db) -> NodeOut:
return _owned_node(db, user, sn)
@router.post("/node/heartbeat", response_model=HeartbeatOut)
@@ -121,14 +193,14 @@ def report(body: TaskReportIn, db: Db) -> TaskOut:
@router.get("/nodes/{sn}/contribution", response_model=ContributionOut)
def contribution(sn: str, yield_date: date, db: Db) -> ContributionOut:
node = get_node_by_sn(db, sn)
def contribution(sn: str, yield_date: date, user: CurrentUser, db: Db) -> ContributionOut:
node = _owned_node(db, user, sn)
computed = compute_node_yield(db, node, yield_date)
return ContributionOut(sn=node.sn, **computed.__dict__)
@router.post("/yield/settle", response_model=SettleOut)
def settle(db: Db, yield_date: date | None = None) -> SettleOut:
def settle(_: AdminUser, db: Db, yield_date: date | None = None) -> SettleOut:
target = yield_date or (utcnow().date() - timedelta(days=1))
summary = settle_yield_date(db, target)
return SettleOut(
@@ -141,52 +213,91 @@ def settle(db: Db, yield_date: date | None = None) -> SettleOut:
@router.get("/nodes/{sn}/yields", response_model=list[YieldOut])
def yields(sn: str, db: Db) -> list[YieldOut]:
def yields(sn: str, user: CurrentUser, db: Db) -> list[YieldOut]:
_owned_node(db, user, sn)
return list_node_yields(db, sn)
@router.get("/withdrawals", response_model=list[WithdrawalView])
def list_withdrawals(db: Db) -> list[WithdrawalView]:
rows = db.scalars(
select(WithdrawalLog).options(selectinload(WithdrawalLog.user)).order_by(WithdrawalLog.id.desc())
).all()
return [
WithdrawalView(
**WithdrawalOut.model_validate(row).model_dump(),
wallet_address=row.user.wallet_address,
)
for row in rows
]
def list_withdrawals(_: AdminUser, db: Db) -> list[WithdrawalView]:
return [_withdrawal_view(row) for row in _withdrawal_rows(db)]
@router.get("/users/{wallet_address}/balance", response_model=BalanceOut)
def balance(wallet_address: str, db: Db) -> BalanceOut:
user = get_user_by_wallet(db, wallet_address)
return BalanceOut(wallet_address=user.wallet_address, available_points=available_balance(db, user.id))
def balance(wallet_address: str, user: CurrentUser, db: Db) -> BalanceOut:
_must_own_wallet(user, wallet_address)
owner = get_user_by_wallet(db, wallet_address)
return BalanceOut(wallet_address=owner.wallet_address, available_points=available_balance(db, owner.id))
@router.post("/withdrawals", response_model=WithdrawalOut)
def create_withdrawal(body: WithdrawalCreate, db: Db) -> WithdrawalOut:
def create_withdrawal(body: WithdrawalCreate, user: CurrentUser, db: Db) -> WithdrawalOut:
_must_own_wallet(user, body.wallet_address)
row, remaining = request_withdrawal(db, body.wallet_address, body.amount, body.asset_symbol)
return WithdrawalOut.model_validate(row).model_copy(update={"available_after": remaining})
@router.post("/withdrawals/{withdrawal_id}/confirm", response_model=WithdrawalOut)
def confirm(withdrawal_id: int, body: WithdrawalConfirm, db: Db) -> WithdrawalOut:
def confirm(withdrawal_id: int, body: WithdrawalConfirm, _: AdminUser, db: Db) -> WithdrawalOut:
return confirm_withdrawal(db, withdrawal_id, body.tx_hash)
@router.post("/withdrawals/{withdrawal_id}/reject", response_model=WithdrawalOut)
def reject(withdrawal_id: int, body: WithdrawalReject, db: Db) -> WithdrawalOut:
def reject(withdrawal_id: int, body: WithdrawalReject, _: AdminUser, db: Db) -> WithdrawalOut:
return reject_withdrawal(db, withdrawal_id, body.note)
def _node_view(node: ComputeNode) -> NodeView:
return NodeView(**NodeOut.model_validate(node).model_dump(), wallet_address=node.user.wallet_address)
def _summary(db: Session) -> SummaryOut:
users = db.scalar(select(func.count()).select_from(User)) or 0
nodes, online = node_counts(db)
return SummaryOut(
users=users,
nodes=nodes,
online=online,
offline=nodes - online,
authorized_amount=authorized_amount(db),
settled_points=settled_points(db),
pending_withdrawals=pending_withdrawal_amount(db),
)
def _sum(values: list[object]) -> Decimal:
return money(sum((to_decimal(item) for item in values), Decimal("0")))
def _token(user: User) -> TokenOut:
return TokenOut(
access_token=create_access_token(user),
role=user.role,
wallet_address=user.wallet_address,
username=user.username,
)
def _must_own_wallet(user: User, wallet_address: str) -> None:
if user.role != UserRole.ADMIN and user.wallet_address != wallet_address.strip():
raise AppError("只能操作自己的钱包", 403)
def _owned_node(db: Session, user: User, sn: str) -> ComputeNode:
node = get_node_by_sn(db, sn)
if user.role != UserRole.ADMIN and node.user_id != user.id:
raise AppError("无权查看该节点", 403)
return node
def _withdrawal_rows(db: Session) -> list[WithdrawalLog]:
pending_first = case((WithdrawalLog.status == WithdrawalStatus.PENDING, 0), else_=1)
return list(
db.scalars(
select(WithdrawalLog)
.options(selectinload(WithdrawalLog.user))
.order_by(pending_first, WithdrawalLog.id.desc())
).all()
)
def _withdrawal_view(row: WithdrawalLog) -> WithdrawalView:
return WithdrawalView(
**WithdrawalOut.model_validate(row).model_dump(),
wallet_address=row.user.wallet_address,
)
def _task_out(task: ComputeTask) -> TaskOut:
+5
View File
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
yield_minute: int = 5
license_months: int = 12
disable_scheduler: bool = False
jwt_secret: str = ""
jwt_expire_minutes: int = 60 * 24
admin_username: str = "admin"
admin_password: str = ""
admin_wallet: str = "admin-root"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
+36
View File
@@ -0,0 +1,36 @@
"""从 Authorization 头解析当前用户,并区分管理员。"""
from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.errors import AppError
from app.models import User, UserRole
from app.security import read_user_id
_bearer = HTTPBearer(auto_error=False)
def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
db: Annotated[Session, Depends(get_db)],
) -> User:
if credentials is None or credentials.scheme.lower() != "bearer":
raise AppError("未登录", 401)
user = db.get(User, read_user_id(credentials.credentials))
if user is None:
raise AppError("登录已失效", 401)
return user
def require_admin(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.role != UserRole.ADMIN:
raise AppError("需要管理员权限", 403)
return user
CurrentUser = Annotated[User, Depends(get_current_user)]
AdminUser = Annotated[User, Depends(require_admin)]
+36 -12
View File
@@ -12,9 +12,12 @@ from fastapi.staticfiles import StaticFiles
import app.models # noqa: F401 注册全部表
from app.api import router
from app.database import Base, engine
from app.database import Base, engine, session_scope
from app.errors import AppError
from app.scheduler import start_scheduler, stop_scheduler
from app.schema_upgrade import upgrade_schema
from app.security import ensure_jwt_secret
from app.services.auth import ensure_admin
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@@ -22,6 +25,10 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
@asynccontextmanager
async def lifespan(_: FastAPI):
Base.metadata.create_all(bind=engine)
upgrade_schema()
with session_scope() as session:
ensure_jwt_secret(session)
ensure_admin(session)
start_scheduler()
try:
yield
@@ -33,19 +40,36 @@ WEB_DIR = Path(__file__).resolve().parent.parent / "web"
app = FastAPI(title="HyperNode-AI", version="1.0.0", lifespan=lifespan)
app.include_router(router)
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")
@app.get("/")
def console() -> FileResponse:
return FileResponse(WEB_DIR / "index.html")
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
_assets = WEB_DIR / "assets"
if _assets.is_dir():
app.mount("/assets", StaticFiles(directory=_assets), name="assets")
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/")
def console() -> FileResponse:
return _spa("")
@app.get("/{full_path:path}")
def console_routes(full_path: str) -> FileResponse:
return _spa(full_path)
def _spa(full_path: str) -> FileResponse:
root = WEB_DIR.resolve()
index = root / "index.html"
if full_path:
candidate = (root / full_path).resolve()
if candidate.is_file() and (candidate == root or root in candidate.parents):
return FileResponse(candidate)
return FileResponse(index)
@app.exception_handler(AppError)
async def handle_app_error(_: Request, exc: AppError) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
+17
View File
@@ -98,6 +98,11 @@ class PushStatus(str, enum.Enum):
FAILED = "failed"
class UserRole(str, enum.Enum):
USER = "user"
ADMIN = "admin"
class User(Base):
"""用户。一个钱包地址对应一名用户,可绑定多台算力节点。"""
@@ -105,6 +110,9 @@ class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
wallet_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
username: Mapped[str | None] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str | None] = mapped_column(String(255))
role: Mapped[UserRole] = mapped_column(_enum(UserRole), default=UserRole.USER, index=True)
created_at: Mapped[datetime] = mapped_column(UtcDateTime, default=utcnow)
nodes: Mapped[list[ComputeNode]] = relationship(back_populates="user")
@@ -241,6 +249,15 @@ class WithdrawalLog(Base):
user: Mapped[User] = relationship(back_populates="withdrawals")
class AppSetting(Base):
"""进程重启后仍要保留的运行时密钥,例如未写入环境变量的 JWT 密钥。"""
__tablename__ = "app_settings"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(Text)
class SchedulerLock(Base):
"""定时任务锁。锁到期后其他实例才能接手,进程崩溃不会永久占住任务。"""
+35
View File
@@ -0,0 +1,35 @@
"""给已经上线的库补上认证字段。create_all 不会修改旧表。"""
from sqlalchemy import inspect, text
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")}
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):
return
with engine.begin() as conn:
for statement in statements:
conn.execute(text(statement))
if not _has_username_index(inspector):
conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_users_username ON users (username)"))
def _has_username_index(inspector) -> bool:
if "users" not in inspector.get_table_names():
return False
for index in inspector.get_indexes("users"):
if index.get("column_names") == ["username"] and index.get("unique"):
return True
return False
+58 -1
View File
@@ -8,10 +8,11 @@ from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer
from app.models import NodeStatus, TaskStatus, WithdrawalStatus, YieldStatus
from app.models import NodeStatus, 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:_-]+$")]
USERNAME = Annotated[str, Field(min_length=3, max_length=32, pattern=r"^[A-Za-z0-9_]+$")]
MoneyText = Annotated[Decimal, PlainSerializer(lambda value: format(value, "f"), return_type=str)]
@@ -26,9 +27,41 @@ class UserCreate(APIModel):
class UserOut(APIModel):
id: int
wallet_address: str
username: str | None = None
role: UserRole = UserRole.USER
created_at: datetime
class RegisterIn(APIModel):
wallet_address: WALLET
username: USERNAME | None = None
password: str | None = Field(default=None, max_length=72)
class LoginIn(APIModel):
wallet_address: str | None = Field(default=None, max_length=128)
username: str | None = Field(default=None, max_length=32)
password: str | None = Field(default=None, max_length=72)
class TokenOut(APIModel):
access_token: str
token_type: str = "bearer"
role: UserRole
wallet_address: str
username: str | None = None
class AdminUserOut(APIModel):
id: int
wallet_address: str
username: str | None
role: UserRole
created_at: datetime
node_count: int
available_points: MoneyText
class NodeCreate(APIModel):
wallet_address: WALLET
sn: SN
@@ -51,6 +84,8 @@ class NodeOut(APIModel):
class NodeView(NodeOut):
wallet_address: str
online_rate: MoneyText = Decimal("0")
today_points: MoneyText = Decimal("0")
class HeartbeatIn(APIModel):
@@ -134,6 +169,17 @@ class WithdrawalCreate(APIModel):
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
class MeWithdrawalCreate(APIModel):
amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
asset_symbol: str = Field(default="POINT", min_length=1, max_length=32)
class MeNodeCreate(APIModel):
sn: SN
purchase_amount: Decimal = Field(gt=0, max_digits=24, decimal_places=8)
callback_url: str | None = None
class WithdrawalConfirm(APIModel):
tx_hash: str = Field(min_length=6, max_length=128, pattern=r"^[A-Za-z0-9]+$")
@@ -172,3 +218,14 @@ class SummaryOut(APIModel):
authorized_amount: MoneyText
settled_points: MoneyText
pending_withdrawals: MoneyText
class DashboardOut(APIModel):
wallet_address: str
username: str | None
role: UserRole
available_points: MoneyText
settled_points: MoneyText
today_points: MoneyText
nodes: list[NodeView]
withdrawals: list[WithdrawalOut]
+87
View File
@@ -0,0 +1,87 @@
"""密码哈希与 JWT。密钥优先用环境变量,否则写入 app_settings,重启后仍然有效。"""
from __future__ import annotations
import hashlib
import hmac
import secrets
from base64 import urlsafe_b64decode, urlsafe_b64encode
from datetime import timedelta
import jwt
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import AppSetting, User
_secret: str | None = None
_ITERATIONS = 200_000
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, _ITERATIONS)
return f"pbkdf2_sha256${_ITERATIONS}${_b64(salt)}${_b64(digest)}"
def verify_password(password: str, stored: str | None) -> bool:
if not stored:
return False
try:
algo, rounds, salt_b64, digest_b64 = stored.split("$", 3)
if algo != "pbkdf2_sha256":
return False
got = hashlib.pbkdf2_hmac("sha256", password.encode(), _unb64(salt_b64), int(rounds))
return hmac.compare_digest(got, _unb64(digest_b64))
except (ValueError, TypeError):
return False
def ensure_jwt_secret(session: Session) -> None:
global _secret
if settings.jwt_secret:
_secret = settings.jwt_secret
return
row = session.get(AppSetting, "jwt_secret")
if row is None:
row = AppSetting(key="jwt_secret", value=secrets.token_hex(32))
session.add(row)
session.flush()
_secret = row.value
def create_access_token(user: User) -> str:
payload = {
"sub": str(user.id),
"role": user.role.value,
"exp": utcnow() + timedelta(minutes=settings.jwt_expire_minutes),
}
return jwt.encode(payload, _require_secret(), algorithm="HS256")
def read_user_id(token: str) -> int:
try:
payload = jwt.decode(token, _require_secret(), algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise AppError("登录已失效", 401) from exc
subject = payload.get("sub")
if not subject:
raise AppError("登录已失效", 401)
return int(subject)
def _require_secret() -> str:
if not _secret:
raise AppError("认证尚未初始化", 500)
return _secret
def _b64(raw: bytes) -> str:
return urlsafe_b64encode(raw).decode().rstrip("=")
def _unb64(text: str) -> bytes:
padding = "=" * (-len(text) % 4)
return urlsafe_b64decode(text + padding)
+103
View File
@@ -0,0 +1,103 @@
"""注册、登录,以及首次启动时创建管理员。"""
from __future__ import annotations
import logging
import secrets
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.clock import utcnow
from app.config import settings
from app.errors import AppError
from app.models import User, UserRole
from app.security import hash_password, verify_password
from app.services.registry import register_user
logger = logging.getLogger(__name__)
def register_account(
session: Session,
wallet_address: str,
username: str | None,
password: str | None,
) -> User:
name = _clean_username(username)
secret = (password or "").strip()
if name and not secret:
raise AppError("设置用户名时必须填写密码")
if secret and not name:
raise AppError("设置密码时必须填写用户名")
if secret and len(secret) < 6:
raise AppError("密码至少 6 位")
return register_user(
session,
wallet_address,
username=name,
password_hash=hash_password(secret) if secret else None,
)
def authenticate(
session: Session,
wallet_address: str | None,
username: str | None,
password: str | None,
) -> User:
name = _clean_username(username)
wallet = (wallet_address or "").strip()
secret = (password or "").strip()
if name and secret:
user = session.scalar(select(User).where(User.username == name))
if user is None or not verify_password(secret, user.password_hash):
raise AppError("账号或密码错误", 401)
return user
if wallet and secret:
user = session.scalar(select(User).where(User.wallet_address == wallet))
if user is None or not verify_password(secret, user.password_hash):
raise AppError("账号或密码错误", 401)
return user
if wallet:
user = session.scalar(select(User).where(User.wallet_address == wallet))
if user is None:
raise AppError("钱包未注册", 401)
if user.role == UserRole.ADMIN:
raise AppError("管理员请使用账号密码登录", 403)
return user
raise AppError("请填写钱包地址,或账号和密码")
def ensure_admin(session: Session) -> None:
username = _clean_username(settings.admin_username) or "admin"
existing = session.scalar(select(User).where(User.username == username))
if existing is not None:
if existing.role != UserRole.ADMIN:
existing.role = UserRole.ADMIN
session.flush()
return
generated = not settings.admin_password
password = settings.admin_password or secrets.token_urlsafe(12)
wallet = settings.admin_wallet.strip() or "admin-root"
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
wallet = f"{wallet}-{secrets.token_hex(3)}"
session.add(
User(
wallet_address=wallet,
username=username,
password_hash=hash_password(password),
role=UserRole.ADMIN,
created_at=utcnow(),
)
)
session.flush()
if generated:
logger.warning("已创建管理员 %s,初始密码:%s", username, password)
def _clean_username(username: str | None) -> str | None:
if username is None:
return None
cleaned = username.strip().lower()
return cleaned or None
+90
View File
@@ -0,0 +1,90 @@
"""控制台要看的实时快照。今日积分按产出公式预览,不写入流水。"""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.clock import utcnow
from app.models import ComputeNode, NodeStatus, NodeYield, User, WithdrawalLog, WithdrawalStatus, YieldStatus
from app.money import money, to_decimal
from app.services.withdrawals import available_balance
from app.services.yield_engine import compute_node_yield
def settled_points(session: Session, user_id: int | None = None) -> Decimal:
query = select(NodeYield.points_delta).where(NodeYield.status == YieldStatus.SETTLED)
if user_id is not None:
query = query.where(NodeYield.user_id == user_id)
rows = session.scalars(query).all()
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
def pending_withdrawal_amount(session: Session) -> Decimal:
rows = session.scalars(
select(WithdrawalLog.amount).where(WithdrawalLog.status == WithdrawalStatus.PENDING)
).all()
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
def authorized_amount(session: Session) -> Decimal:
rows = session.scalars(select(ComputeNode.purchase_amount)).all()
return money(sum((to_decimal(item) for item in rows), Decimal("0")))
def node_counts(session: Session) -> tuple[int, int]:
nodes = session.scalar(select(func.count()).select_from(ComputeNode)) or 0
online = session.scalar(
select(func.count()).select_from(ComputeNode).where(ComputeNode.status == NodeStatus.ONLINE)
) or 0
return nodes, online
def live_nodes(session: Session, user_id: int | None = None) -> list[dict]:
query = select(ComputeNode).options(selectinload(ComputeNode.user)).order_by(ComputeNode.id.desc())
if user_id is not None:
query = query.where(ComputeNode.user_id == user_id)
today = utcnow().date()
snapshots = []
for node in session.scalars(query).all():
computed = compute_node_yield(session, node, today)
snapshots.append(
{
"id": node.id,
"sn": node.sn,
"user_id": node.user_id,
"wallet_address": node.user.wallet_address,
"purchase_amount": node.purchase_amount,
"license_months": node.license_months,
"licensed_at": node.licensed_at,
"license_expires_at": node.license_expires_at,
"status": node.status,
"last_heartbeat_at": node.last_heartbeat_at,
"callback_url": node.callback_url,
"online_rate": computed.online_rate,
"today_points": computed.points_delta if computed.eligible else Decimal("0"),
}
)
return snapshots
def user_rows(session: Session) -> list[dict]:
counts = dict(
session.execute(select(ComputeNode.user_id, func.count()).group_by(ComputeNode.user_id)).all()
)
rows = []
for user in session.scalars(select(User).order_by(User.id.desc())).all():
rows.append(
{
"id": user.id,
"wallet_address": user.wallet_address,
"username": user.username,
"role": user.role,
"created_at": user.created_at,
"node_count": int(counts.get(user.id, 0)),
"available_points": available_balance(session, user.id),
}
)
return rows
+18 -4
View File
@@ -12,22 +12,36 @@ from sqlalchemy.orm import Session
from app.clock import add_months, utcnow
from app.config import settings
from app.errors import AppError
from app.models import ComputeNode, NodeStatus, User
from app.models import ComputeNode, NodeStatus, User, UserRole
from app.money import money
from app.services.common import get_user_by_wallet
def register_user(session: Session, wallet_address: str) -> User:
def register_user(
session: Session,
wallet_address: str,
username: str | None = None,
password_hash: str | None = None,
role: UserRole = UserRole.USER,
) -> User:
wallet = wallet_address.strip()
if session.scalar(select(User).where(User.wallet_address == wallet)) is not None:
raise AppError("钱包地址已注册", 409)
user = User(wallet_address=wallet, created_at=utcnow())
if username and session.scalar(select(User).where(User.username == username)) is not None:
raise AppError("用户名已占用", 409)
user = User(
wallet_address=wallet,
username=username,
password_hash=password_hash,
role=role,
created_at=utcnow(),
)
session.add(user)
try:
session.flush()
except IntegrityError as exc:
session.rollback()
raise AppError("钱包地址已注册", 409) from exc
raise AppError("钱包地址或用户名已注册", 409) from exc
return user
+15
View File
@@ -21,6 +21,7 @@ SERVICE_NAME="hypernode"
BIND_HOST="0.0.0.0"
BIND_PORT="33333"
ENV_FILE="${INSTALL_ROOT}/.env"
GENERATED_ADMIN_PASSWORD=""
_script_src="${BASH_SOURCE[0]:-}"
if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then
@@ -132,6 +133,10 @@ write_env() {
log "保留已有配置 ${ENV_FILE}"
return 0
fi
local jwt_secret admin_password
jwt_secret="$(openssl rand -hex 32)"
admin_password="$(openssl rand -hex 12)"
GENERATED_ADMIN_PASSWORD="${admin_password}"
log "写入 ${ENV_FILE}"
cat >"${ENV_FILE}" <<EOF
DATABASE_URL=sqlite:////${DATA_DIR}/hypernode.db
@@ -139,6 +144,10 @@ YIELD_TIMEZONE=Asia/Shanghai
YIELD_HOUR=0
YIELD_MINUTE=5
DISABLE_SCHEDULER=false
JWT_SECRET=${jwt_secret}
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${admin_password}
ADMIN_WALLET=admin-root
EOF
}
@@ -220,7 +229,13 @@ cmd_deploy() {
log "部署完成"
echo "监听地址:${BIND_HOST}:${BIND_PORT}"
echo "健康检查:curl http://127.0.0.1:${BIND_PORT}/health"
echo "控制台:http://<服务器 IP>:${BIND_PORT}/"
echo "接口文档:http://<服务器 IP>:${BIND_PORT}/docs"
if [[ -n "${GENERATED_ADMIN_PASSWORD}" ]]; then
echo "管理员账号:admin"
echo "管理员密码:${GENERATED_ADMIN_PASSWORD}"
echo "密码写在 ${ENV_FILE},请立刻记下。"
fi
echo "程序目录:${INSTALL_ROOT}"
echo "数据库:${DATA_DIR}/hypernode.db"
echo "日志:journalctl -u ${SERVICE_NAME}.service -f"
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HyperNode · 算力控制面</title>
<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" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1285
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "hypernode-console",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^5.4.11"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+45
View File
@@ -0,0 +1,45 @@
const KEYS = ["token", "role", "wallet", "username"];
export function session() {
return {
token: localStorage.getItem("token") || "",
role: localStorage.getItem("role") || "",
wallet: localStorage.getItem("wallet") || "",
username: localStorage.getItem("username") || "",
};
}
export function saveSession(payload) {
localStorage.setItem("token", payload.access_token);
localStorage.setItem("role", payload.role);
localStorage.setItem("wallet", payload.wallet_address);
localStorage.setItem("username", payload.username || "");
}
export function clearSession() {
KEYS.forEach((key) => localStorage.removeItem(key));
}
export async function api(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (options.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const token = localStorage.getItem("token");
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(`/api/v1${path}`, { ...options, headers });
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (response.status === 401) {
clearSession();
if (location.pathname !== "/login") location.assign("/login");
throw new Error(messageOf(data) || "登录已失效");
}
if (!response.ok) throw new Error(messageOf(data) || "请求失败");
return data;
}
function messageOf(data) {
if (!data) return "";
if (typeof data.detail === "string") return data.detail;
if (Array.isArray(data.detail)) return data.detail.map((item) => item.msg).filter(Boolean).join(";");
return "";
}
+79
View File
@@ -0,0 +1,79 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { useRouter } from "vue-router";
import { clearSession, session } from "../api";
defineProps({
eyebrow: { type: String, default: "控制面" },
title: { type: String, required: true },
});
const router = useRouter();
const current = session();
const clock = ref("--:--:--");
let timer = 0;
function tick() {
clock.value = new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZone: "Asia/Shanghai",
}).format(new Date());
}
function logout() {
clearSession();
router.push("/login");
}
onMounted(() => {
tick();
timer = window.setInterval(tick, 1000);
});
onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<div class="shell">
<aside class="rail">
<router-link class="brand" to="/">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<path d="M16 3 L27 9.5 V22.5 L16 29 L5 22.5 V9.5 Z" fill="none" stroke="currentColor" stroke-width="1.4" />
<circle cx="16" cy="16" r="2.2" fill="currentColor" />
</svg>
</span>
<span>
<strong>HyperNode</strong>
<em>Compute Atelier</em>
</span>
</router-link>
<nav class="nav">
<router-link to="/dashboard">我的节点</router-link>
<router-link v-if="current.role === 'admin'" to="/admin">管理后台</router-link>
</nav>
<div class="rail-foot">
<span class="who">{{ current.username || current.wallet }}</span>
<button type="button" class="texty" @click="logout">退出</button>
</div>
</aside>
<div class="stage">
<header class="top">
<div>
<p class="eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
</div>
<div class="clock">
<span>北京时间</span>
<strong>{{ clock }}</strong>
</div>
</header>
<main class="view">
<slot />
</main>
</div>
</div>
</template>
+36
View File
@@ -0,0 +1,36 @@
export function money(value) {
const [whole, fraction = ""] = String(value ?? "0").split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const trimmed = fraction.replace(/0+$/, "");
return trimmed ? `${grouped}.${trimmed}` : grouped;
}
export function pct(value) {
const number = Number(value) * 100;
if (!Number.isFinite(number)) return "0%";
return `${number.toFixed(number >= 10 ? 0 : 1)}%`;
}
export function when(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
}
export const statusText = {
online: "在线",
offline: "离线",
pending: "待审核",
completed: "已打款",
rejected: "已拒绝",
failed: "失败",
settled: "已入账",
user: "用户",
admin: "管理员",
};
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
createApp(App).use(router).mount("#app");
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from "vue-router";
import Login from "../views/Login.vue";
import Dashboard from "../views/Dashboard.vue";
import AdminOverview from "../views/AdminOverview.vue";
import { session } from "../api";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: () => (session().role === "admin" ? "/admin" : "/dashboard") },
{ path: "/login", component: Login },
{ path: "/dashboard", component: Dashboard, meta: { auth: true } },
{ path: "/admin", component: AdminOverview, meta: { auth: true, admin: true } },
],
});
router.beforeEach((to) => {
const current = session();
if (to.meta.auth && !current.token) return "/login";
if (to.meta.admin && current.role !== "admin") return "/dashboard";
if (to.path === "/login" && current.token) return current.role === "admin" ? "/admin" : "/dashboard";
return true;
});
+182
View File
@@ -0,0 +1,182 @@
:root {
--bg: #08090d;
--ink: #f4efe6;
--muted: #a3988a;
--gold: #e0c07a;
--line: rgba(224, 192, 122, 0.18);
--panel: rgba(16, 18, 24, 0.82);
--online: #8ed0b5;
--offline: #d08a78;
--serif: "Fraunces", Palatino, serif;
--sans: "Manrope", "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body, #app { min-height: 100%; }
body {
margin: 0;
color: var(--ink);
font-family: var(--sans);
background:
radial-gradient(980px 520px at 0% -10%, rgba(224, 192, 122, 0.16), transparent 58%),
radial-gradient(760px 480px at 100% 0%, rgba(126, 164, 196, 0.12), transparent 52%),
var(--bg);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(244, 239, 230, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(244, 239, 230, 0.035) 1px, transparent 1px);
background-size: 84px 84px;
mask-image: radial-gradient(circle at 20% 10%, #000 0%, transparent 72%);
}
button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, p { margin: 0; }
a { color: inherit; }
.gate {
min-height: 100vh;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 48px;
align-items: center;
padding: 64px 8vw;
}
.gate-copy h1 {
margin: 12px 0 18px;
font-family: var(--serif);
font-size: clamp(52px, 6vw, 88px);
font-weight: 560;
letter-spacing: -0.04em;
line-height: 0.95;
}
.gate-copy p:last-child { max-width: 420px; color: var(--muted); font-size: 18px; line-height: 1.6; }
.eyebrow {
color: var(--gold);
letter-spacing: 0.18em;
text-transform: uppercase;
font-size: 12px;
}
.gate-card, .panel, .metric, .plate, .table-wrap {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 24px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
}
.gate-card { display: grid; gap: 14px; padding: 28px; }
.switch { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.switch button, .texty, .ghost {
border: 1px solid transparent;
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 10px 14px;
}
.switch button.on { color: var(--ink); background: rgba(224, 192, 122, 0.1); border-color: var(--line); }
.gold {
border: 0;
border-radius: 999px;
padding: 12px 18px;
background: linear-gradient(180deg, #f0d7a2, #c4964e);
color: #24180b;
font-weight: 700;
}
.gold.wide { width: 100%; margin-top: 4px; }
.gold:disabled { opacity: 0.6; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 13px; }
input {
width: 100%;
border: 1px solid var(--line);
background: rgba(8, 9, 13, 0.55);
border-radius: 12px;
padding: 12px 14px;
outline: none;
}
input:focus { border-color: var(--gold); }
.hint, .sub { color: var(--muted); font-size: 13px; line-height: 1.5; }
.form-error { color: #e7b2a6; font-size: 14px; }
.banner { margin-bottom: 14px; }
.banner.ok { color: var(--online); }
.shell { min-height: 100vh; display: grid; grid-template-columns: 248px 1fr; }
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 20px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: rgba(8, 9, 13, 0.72);
}
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; }
.mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
color: var(--gold);
border: 1px solid var(--line);
border-radius: 14px;
}
.brand strong { display: block; font-family: var(--serif); font-size: 22px; font-weight: 560; }
.brand em { display: block; margin-top: 2px; color: var(--muted); font-style: normal; font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; }
.nav { display: grid; gap: 6px; margin-top: 36px; }
.nav a { text-decoration: none; color: var(--muted); border-radius: 12px; padding: 12px 14px; }
.nav a.router-link-active { color: var(--ink); background: rgba(224, 192, 122, 0.08); box-shadow: inset 2px 0 0 var(--gold); }
.rail-foot { margin-top: auto; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.who { color: var(--muted); font-size: 13px; overflow: hidden; text-overflow: ellipsis; }
.stage { padding: 28px 32px 64px; }
.top { display: flex; justify-content: space-between; align-items: end; gap: 16px; }
.top h1 { margin-top: 8px; font-family: var(--serif); font-size: 56px; font-weight: 560; letter-spacing: -0.04em; }
.clock span { display: block; color: var(--muted); font-size: 11px; letter-spacing: 0.14em; }
.clock strong { font-family: var(--mono); font-size: 20px; font-weight: 500; }
.view { margin-top: 28px; }
.metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; }
.metric { padding: 22px; }
.metric span { color: var(--muted); font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; }
.metric strong { display: block; margin-top: 12px; font-family: var(--serif); font-size: 40px; font-weight: 560; letter-spacing: -0.04em; }
.metric em { display: block; margin-top: 8px; color: var(--muted); font-style: normal; font-size: 13px; }
.section-title { margin: 28px 0 14px; font-family: var(--serif); font-weight: 560; font-size: 28px; }
.board { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; margin-top: 16px; }
.plate { padding: 18px; }
.plate header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.plate header strong { font-family: var(--mono); font-size: 14px; }
.wallet { margin-top: 8px; color: var(--muted); font-family: var(--mono); font-size: 12px; }
.rate { position: relative; overflow: hidden; margin: 14px 0; padding: 8px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.03); color: var(--muted); font-size: 12px; }
.rate b { position: absolute; inset: 0 auto 0 0; background: rgba(224, 192, 122, 0.18); }
.plate dl { display: grid; gap: 8px; margin: 0; }
.plate dl div { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
.plate dt { color: var(--muted); }
.plate dd { margin: 0; }
.pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 10px; border: 1px solid var(--line); font-size: 12px; }
.pill i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.pill.online, .pill.completed { color: var(--online); }
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
.pill.pending { color: var(--gold); }
.forms { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; align-items: start; }
.panel { display: grid; gap: 12px; padding: 22px; }
.panel h2 { font-family: var(--serif); font-size: 28px; font-weight: 560; }
.table-wrap { overflow: auto; margin-top: 8px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 14px 16px; text-align: left; border-bottom: 1px solid rgba(224, 192, 122, 0.08); font-size: 14px; }
th { color: var(--muted); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; }
td input { width: 180px; padding: 8px 10px; }
.actions { display: flex; gap: 8px; }
.mono { font-family: var(--mono); font-size: 12px; }
.empty { padding: 28px 16px; color: var(--muted); }
.mini { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 8px; }
.mini li { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
@media (max-width: 1100px) {
.metrics, .forms, .gate { grid-template-columns: 1fr; }
.shell { grid-template-columns: 1fr; }
.rail { position: relative; height: auto; }
.top h1 { font-size: 40px; }
}
+170
View File
@@ -0,0 +1,170 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import Shell from "../components/Shell.vue";
import { api } from "../api";
import { money, pct, statusText, when } from "../format";
const summary = ref(null);
const users = ref([]);
const nodes = ref([]);
const withdrawals = ref([]);
const hashes = ref({});
const error = ref("");
const notice = ref("");
let timer = 0;
async function load() {
const [nextSummary, nextUsers, nextNodes, nextCash] = await Promise.all([
api("/admin/summary"),
api("/admin/users"),
api("/admin/nodes"),
api("/admin/withdrawals"),
]);
summary.value = nextSummary;
users.value = nextUsers;
nodes.value = nextNodes;
withdrawals.value = nextCash;
}
async function confirm(row) {
const txHash = (hashes.value[row.id] || "").trim();
if (!txHash) {
error.value = "请先填写链上哈希";
return;
}
error.value = "";
notice.value = "";
try {
await api(`/admin/withdrawals/${row.id}/confirm`, {
method: "POST",
body: JSON.stringify({ tx_hash: txHash }),
});
notice.value = `提现 #${row.id} 已打款`;
await load();
} catch (err) {
error.value = err.message;
}
}
async function reject(row) {
error.value = "";
notice.value = "";
try {
await api(`/admin/withdrawals/${row.id}/reject`, {
method: "POST",
body: JSON.stringify({ note: "管理后台拒绝" }),
});
notice.value = `提现 #${row.id} 已拒绝`;
await load();
} catch (err) {
error.value = err.message;
}
}
onMounted(async () => {
try {
await load();
} catch (err) {
error.value = err.message;
}
timer = window.setInterval(() => {
load().catch((err) => {
error.value = err.message;
});
}, 10000);
});
onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<Shell eyebrow="管理后台" title="全网概览">
<p v-if="error" class="form-error banner">{{ error }}</p>
<p v-if="notice" class="banner ok">{{ notice }}</p>
<section v-if="summary" class="metrics">
<article class="metric">
<span>用户</span>
<strong>{{ summary.users }}</strong>
<em>已注册钱包</em>
</article>
<article class="metric">
<span>节点</span>
<strong>{{ summary.nodes }}</strong>
<em>在线 {{ summary.online }} · 离线 {{ summary.offline }}</em>
</article>
<article class="metric">
<span>总产出</span>
<strong>{{ money(summary.settled_points) }}</strong>
<em>授权 {{ money(summary.authorized_amount) }}</em>
</article>
<article class="metric">
<span>待审核</span>
<strong>{{ money(summary.pending_withdrawals) }}</strong>
<em>占用中的提现</em>
</article>
</section>
<h2 class="section-title">节点监控</h2>
<section class="board">
<article v-for="node in nodes" :key="node.sn" class="plate">
<header>
<strong>{{ node.sn }}</strong>
<span class="pill" :class="node.status"><i />{{ statusText[node.status] }}</span>
</header>
<p class="wallet">{{ node.wallet_address }}</p>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线率</p>
<dl>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
</dl>
</article>
<p v-if="!nodes.length" class="empty">还没有节点。</p>
</section>
<h2 class="section-title">提现审核</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th>链上哈希</th><th></th></tr>
</thead>
<tbody>
<tr v-for="row in withdrawals" :key="row.id">
<td>#{{ row.id }}</td>
<td class="mono">{{ row.wallet_address }}</td>
<td>{{ money(row.amount) }} {{ row.asset_symbol }}</td>
<td><span class="pill" :class="row.status">{{ statusText[row.status] }}</span></td>
<td>
<input v-if="row.status === 'pending'" v-model="hashes[row.id]" placeholder="交易哈希" />
<span v-else class="mono">{{ row.tx_hash || "—" }}</span>
</td>
<td class="actions" v-if="row.status === 'pending'">
<button class="gold" type="button" @click="confirm(row)">打款</button>
<button class="texty" type="button" @click="reject(row)">拒绝</button>
</td>
<td v-else></td>
</tr>
<tr v-if="!withdrawals.length"><td colspan="6" class="empty">还没有提现申请。</td></tr>
</tbody>
</table>
</div>
<h2 class="section-title">用户</h2>
<div class="table-wrap">
<table>
<thead>
<tr><th>钱包</th><th>用户名</th><th>角色</th><th>节点</th><th>可用积分</th><th>注册</th></tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td class="mono">{{ user.wallet_address }}</td>
<td>{{ user.username || "—" }}</td>
<td>{{ statusText[user.role] }}</td>
<td>{{ user.node_count }}</td>
<td>{{ money(user.available_points) }}</td>
<td>{{ when(user.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</Shell>
</template>
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import Shell from "../components/Shell.vue";
import { api } 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 cashForm = ref({ amount: "", asset_symbol: "POINT" });
let timer = 0;
async function load() {
board.value = await api("/me/dashboard");
}
async function run(work, done) {
error.value = "";
notice.value = "";
try {
await work();
notice.value = done;
await load();
} catch (err) {
error.value = err.message;
}
}
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();
return run(async () => {
await api("/me/nodes", { method: "POST", body: JSON.stringify(body) });
nodeForm.value = { sn: "", purchase_amount: "", callback_url: "" };
}, "节点已登记");
}
function requestCash() {
return run(async () => {
await api("/me/withdrawals", {
method: "POST",
body: JSON.stringify({
amount: cashForm.value.amount,
asset_symbol: cashForm.value.asset_symbol || "POINT",
}),
});
cashForm.value.amount = "";
}, "提现申请已提交");
}
onMounted(async () => {
try {
await load();
} catch (err) {
error.value = err.message;
}
timer = window.setInterval(() => {
load().catch((err) => {
error.value = err.message;
});
}, 10000);
});
onUnmounted(() => window.clearInterval(timer));
</script>
<template>
<Shell eyebrow="用户控制台" title="我的算力">
<p v-if="error" class="form-error banner">{{ error }}</p>
<p v-if="notice" class="banner ok">{{ notice }}</p>
<section v-if="board" class="metrics">
<article class="metric">
<span>可用积分</span>
<strong>{{ money(board.available_points) }}</strong>
<em>已入账 {{ money(board.settled_points) }}</em>
</article>
<article class="metric">
<span>今日预计</span>
<strong>{{ money(board.today_points) }}</strong>
<em>按当前在线实时估算</em>
</article>
<article class="metric">
<span>节点</span>
<strong>{{ board.nodes.length }}</strong>
<em>{{ board.nodes.filter((node) => node.status === "online").length }} 台在线</em>
</article>
</section>
<section class="board">
<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>
</header>
<p class="rate"><b :style="{ width: pct(node.online_rate) }" />{{ pct(node.online_rate) }} 今日在线</p>
<dl>
<div><dt>购买金额</dt><dd>{{ money(node.purchase_amount) }}</dd></div>
<div><dt>今日积分</dt><dd>{{ money(node.today_points) }}</dd></div>
<div><dt>最近心跳</dt><dd>{{ when(node.last_heartbeat_at) }}</dd></div>
</dl>
</article>
<p v-if="board && !board.nodes.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>
<form class="panel" @submit.prevent="requestCash">
<h2>申请提现</h2>
<p class="sub">提交后金额会被占用,等管理员打款。</p>
<label>金额<input v-model="cashForm.amount" required placeholder="1.25" /></label>
<label>资产<input v-model="cashForm.asset_symbol" /></label>
<button class="gold" type="submit">提交申请</button>
<ul class="mini">
<li v-for="row in board?.withdrawals || []" :key="row.id">
<span>#{{ row.id }} · {{ money(row.amount) }} {{ row.asset_symbol }}</span>
<span class="pill" :class="row.status">{{ statusText[row.status] }}</span>
</li>
</ul>
</form>
</section>
</Shell>
</template>
+102
View File
@@ -0,0 +1,102 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { api, saveSession } from "../api";
const router = useRouter();
const panel = ref("login");
const method = ref("wallet");
const error = ref("");
const pending = ref(false);
const form = ref({
wallet_address: "",
username: "",
password: "",
confirm: "",
});
async function submit() {
error.value = "";
if (panel.value === "register" && form.value.password !== form.value.confirm) {
error.value = "两次密码不一致";
return;
}
pending.value = true;
try {
const payload = panel.value === "register" ? await register() : await login();
saveSession(payload);
router.push(payload.role === "admin" ? "/admin" : "/dashboard");
} catch (err) {
error.value = err.message;
} finally {
pending.value = false;
}
}
function login() {
if (method.value === "wallet") {
return api("/auth/login", {
method: "POST",
body: JSON.stringify({ wallet_address: form.value.wallet_address.trim() }),
});
}
return api("/auth/login", {
method: "POST",
body: JSON.stringify({
username: form.value.username.trim(),
password: form.value.password,
}),
});
}
function register() {
return api("/auth/register", {
method: "POST",
body: JSON.stringify({
wallet_address: form.value.wallet_address.trim(),
username: form.value.username.trim(),
password: form.value.password,
}),
});
}
</script>
<template>
<div class="gate">
<section class="gate-copy">
<p class="eyebrow">HyperNode</p>
<h1>算力在这里入账。</h1>
<p>钱包或账号进入自己的节点。管理员从这里审核提现、看全网在线。</p>
</section>
<form class="gate-card" @submit.prevent="submit">
<div class="switch">
<button type="button" :class="{ on: panel === 'login' }" @click="panel = 'login'">登录</button>
<button type="button" :class="{ on: panel === 'register' }" @click="panel = 'register'">注册</button>
</div>
<div v-if="panel === 'login'" class="switch slim">
<button type="button" :class="{ on: method === 'wallet' }" @click="method = 'wallet'">钱包地址</button>
<button type="button" :class="{ on: method === 'account' }" @click="method = 'account'">账号密码</button>
</div>
<label v-if="panel === 'register' || method === 'wallet'">
钱包地址
<input v-model="form.wallet_address" required minlength="4" placeholder="0xabc123" autocomplete="username" />
</label>
<label v-if="panel === 'register' || method === 'account'">
用户名
<input v-model="form.username" required minlength="3" maxlength="32" placeholder="miner" autocomplete="username" />
</label>
<label v-if="panel === 'register' || method === 'account'">
密码
<input v-model="form.password" required minlength="6" type="password" autocomplete="current-password" />
</label>
<label v-if="panel === 'register'">
确认密码
<input v-model="form.confirm" required minlength="6" type="password" autocomplete="new-password" />
</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>
</form>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
build: {
outDir: "../web",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:8000",
"/health": "http://127.0.0.1:8000",
},
},
});
+1
View File
@@ -6,3 +6,4 @@ pydantic>=2.6
pydantic-settings>=2.2
httpx>=0.27
tzdata>=2024.1
PyJWT>=2.8
+77 -6
View File
@@ -13,6 +13,10 @@ from pathlib import Path
_db_path = Path(tempfile.gettempdir()) / "depin_core_flow.db"
os.environ["DATABASE_URL"] = "sqlite:///" + _db_path.as_posix()
os.environ["DISABLE_SCHEDULER"] = "1"
os.environ["JWT_SECRET"] = "test-secret-test-secret-test-secret"
os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "test-admin-pass"
os.environ["ADMIN_WALLET"] = "admin-root"
from fastapi.testclient import TestClient
from sqlalchemy import select
@@ -129,7 +133,11 @@ class CoreFlowTest(unittest.TestCase):
session.add(self._task(miss.id, TaskStatus.FAILED, day_start))
session.commit()
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
settled = self.client.post(
"/api/v1/yield/settle",
headers=self._admin_headers(),
params={"yield_date": yesterday.isoformat()},
)
self.assertEqual(settled.status_code, 200, settled.text)
self.assertEqual(settled.json()["created"], 3)
@@ -153,11 +161,19 @@ class CoreFlowTest(unittest.TestCase):
self.assertEqual(miss_row.points_delta, Decimal("0.70000000"))
self.assertEqual(miss_row.compute_output, Decimal("255.50000000"))
again = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
again = self.client.post(
"/api/v1/yield/settle",
headers=self._admin_headers(),
params={"yield_date": yesterday.isoformat()},
)
self.assertEqual(again.json()["created"], 0)
self.assertEqual(again.json()["skipped"], 3)
future = self.client.post("/api/v1/yield/settle", params={"yield_date": "2999-01-01"})
future = self.client.post(
"/api/v1/yield/settle",
headers=self._admin_headers(),
params={"yield_date": "2999-01-01"},
)
self.assertEqual(future.status_code, 400, future.text)
def test_open_interval_stops_at_last_heartbeat(self) -> None:
@@ -173,7 +189,11 @@ class CoreFlowTest(unittest.TestCase):
session.add(UptimeInterval(node_id=node.id, started_at=day_start, ended_at=None))
session.commit()
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
settled = self.client.post(
"/api/v1/yield/settle",
headers=self._admin_headers(),
params={"yield_date": yesterday.isoformat()},
)
self.assertEqual(settled.status_code, 200, settled.text)
row = self._yield_row("NODE-HALF")
self.assertEqual(row.online_seconds, 12 * 60 * 60)
@@ -205,17 +225,24 @@ class CoreFlowTest(unittest.TestCase):
day_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=1)
self._register("0xpay", "NODE-PAY", "365")
self._cover_full_day("NODE-PAY", day_start, day_start + timedelta(days=1))
settled = self.client.post("/api/v1/yield/settle", params={"yield_date": yesterday.isoformat()})
payer = self._auth("0xpay")
settled = self.client.post(
"/api/v1/yield/settle",
headers=self._admin_headers(),
params={"yield_date": yesterday.isoformat()},
)
self.assertEqual(settled.status_code, 200, settled.text)
too_much = self.client.post(
"/api/v1/withdrawals",
headers=payer,
json={"wallet_address": "0xpay", "amount": "10"},
)
self.assertEqual(too_much.status_code, 400, too_much.text)
created = self.client.post(
"/api/v1/withdrawals",
headers=payer,
json={"wallet_address": "0xpay", "amount": "0.4", "asset_symbol": "POINT"},
)
self.assertEqual(created.status_code, 200, created.text)
@@ -224,26 +251,70 @@ class CoreFlowTest(unittest.TestCase):
blocked = self.client.post(
"/api/v1/withdrawals",
headers=payer,
json={"wallet_address": "0xpay", "amount": "0.7"},
)
self.assertEqual(blocked.status_code, 400, blocked.text)
confirmed = self.client.post(
f"/api/v1/withdrawals/{withdrawal_id}/confirm",
headers=self._admin_headers(),
json={"tx_hash": "0xabc123"},
)
self.assertEqual(confirmed.status_code, 200, confirmed.text)
self.assertEqual(confirmed.json()["status"], "completed")
self.assertEqual(confirmed.json()["tx_hash"], "0xabc123")
balance = self.client.get("/api/v1/users/0xpay/balance")
balance = self.client.get("/api/v1/users/0xpay/balance", headers=self._auth("0xpay"))
self.assertEqual(balance.json()["available_points"], "0.60000000")
def test_roles_split_user_and_admin(self) -> None:
registered = self.client.post(
"/api/v1/auth/register",
json={"wallet_address": "0xuser01", "username": "miner", "password": "secret1"},
)
self.assertEqual(registered.status_code, 200, registered.text)
self.assertEqual(registered.json()["role"], "user")
token = registered.json()["access_token"]
user_headers = {"Authorization": f"Bearer {token}"}
by_wallet = self.client.post("/api/v1/auth/login", json={"wallet_address": "0xuser01"})
self.assertEqual(by_wallet.status_code, 200, by_wallet.text)
by_password = self.client.post(
"/api/v1/auth/login",
json={"username": "miner", "password": "secret1"},
)
self.assertEqual(by_password.status_code, 200, by_password.text)
denied = self.client.get("/api/v1/admin/users", headers=user_headers)
self.assertEqual(denied.status_code, 403, denied.text)
opened = self.client.get("/api/v1/admin/summary", headers=self._admin_headers())
self.assertEqual(opened.status_code, 200, opened.text)
self.assertGreaterEqual(opened.json()["users"], 1)
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 _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)
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
def _admin_headers(self) -> dict[str, str]:
logged = self.client.post(
"/api/v1/auth/login",
json={"username": "admin", "password": "test-admin-pass"},
)
self.assertEqual(logged.status_code, 200, logged.text)
self.assertEqual(logged.json()["role"], "admin")
return {"Authorization": f"Bearer {logged.json()['access_token']}"}
def _register(self, wallet: str, sn: str, amount: str) -> None:
user = self.client.post("/api/v1/users", json={"wallet_address": wallet})
self.assertEqual(user.status_code, 200, user.text)
node = self.client.post(
"/api/v1/nodes",
headers=self._auth(wallet),
json={"wallet_address": wallet, "sn": sn, "purchase_amount": amount},
)
self.assertEqual(node.status_code, 200, node.text)
-498
View File
@@ -1,498 +0,0 @@
const viewRoot = document.querySelector("#view");
const titleEl = document.querySelector("#title");
const toastEl = document.querySelector("#toast");
const state = {
view: "overview",
summary: null,
nodes: [],
users: [],
withdrawals: [],
yields: [],
selected: "",
};
const copy = {
overview: "总览",
nodes: "节点",
ledger: "流水",
cash: "提现",
};
document.querySelectorAll("#nav button").forEach((button) => {
button.addEventListener("click", () => go(button.dataset.view));
});
document.querySelector("#refresh").addEventListener("click", () => load().catch(showError));
window.addEventListener("hashchange", syncFromHash);
tick();
setInterval(tick, 1000);
syncFromHash();
load().catch(showError);
probeHealth();
function tick() {
document.querySelector("#clock").textContent = new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).format(new Date());
}
function go(view) {
location.hash = view;
}
async function syncFromHash() {
const next = (location.hash || "#overview").slice(1);
state.view = copy[next] ? next : "overview";
document.querySelectorAll("#nav button").forEach((button) => {
button.classList.toggle("active", button.dataset.view === state.view);
});
titleEl.textContent = copy[state.view];
if (state.view === "ledger" && state.selected) {
try {
state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`);
} catch (error) {
showError(error);
}
}
render();
}
async function load() {
const [summary, nodes, users, withdrawals] = await Promise.all([
api("/summary"),
api("/nodes"),
api("/users"),
api("/withdrawals"),
]);
state.summary = summary;
state.nodes = nodes;
state.users = users;
state.withdrawals = withdrawals;
if (!state.selected && nodes[0]) state.selected = nodes[0].sn;
if (state.view === "ledger" && state.selected) {
state.yields = await api(`/nodes/${encodeURIComponent(state.selected)}/yields`);
}
render();
}
async function probeHealth() {
const dot = document.querySelector("#health-dot");
const text = document.querySelector("#health-text");
try {
const res = await fetch("/health");
if (!res.ok) throw new Error("offline");
dot.classList.add("on");
text.textContent = "服务在线";
} catch {
text.textContent = "服务未响应";
}
}
function render() {
const painters = { overview: paintOverview, nodes: paintNodes, ledger: paintLedger, cash: paintCash };
viewRoot.innerHTML = painters[state.view]();
bind();
}
function paintOverview() {
const s = state.summary || {};
return `
<section class="metrics">
${metric("授权金额", money(s.authorized_amount), `${s.nodes || 0} 台节点`)}
${metric("在线", `${s.online || 0}`, `离线 ${s.offline || 0}`)}
${metric("已入账积分", money(s.settled_points), `${s.users || 0} 个钱包`)}
${metric("待处理提现", money(s.pending_withdrawals), "占用可用余额")}
</section>
<div class="table-wrap" style="margin-top:16px">
<table>
<thead><tr><th>节点</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.slice(0, 8).map(nodeRow).join("") : `<tr><td colspan="5" class="empty">还没有节点。下面可以登记第一台。</td></tr>`}
</tbody>
</table>
</div>
<section class="forms">
${userForm()}
${nodeForm()}
</section>
`;
}
function paintNodes() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>序列号</th><th>钱包</th><th>购买金额</th><th>状态</th><th>到期</th></tr></thead>
<tbody>
${state.nodes.length ? state.nodes.map(nodeRow).join("") : `<tr><td colspan="5" class="empty">节点列表是空的。</td></tr>`}
</tbody>
</table>
</div>
${nodeForm()}
</section>
`;
}
function paintLedger() {
const options = state.nodes.map((node) => `<option value="${esc(node.sn)}" ${node.sn === state.selected ? "selected" : ""}>${esc(node.sn)}</option>`).join("");
return `
<div class="toolbar">
<label class="grow">节点
<select id="ledger-node">${options || `<option value="">暂无节点</option>`}</select>
</label>
<label>清算日期
<input id="settle-date" type="date" />
</label>
<button class="gold" id="settle" type="button">清算</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>日期</th><th>积分</th><th>算力产出</th><th>在线率</th><th>响应率</th><th>表现</th><th>状态</th></tr></thead>
<tbody>
${state.yields.length ? state.yields.map(yieldRow).join("") : `<tr><td colspan="7" class="empty">这一天还没有入账记录。</td></tr>`}
</tbody>
</table>
</div>
`;
}
function paintCash() {
return `
<section class="layout">
<div class="table-wrap">
<table>
<thead><tr><th>单号</th><th>钱包</th><th>金额</th><th>状态</th><th></th></tr></thead>
<tbody>
${state.withdrawals.length ? state.withdrawals.map(cashRow).join("") : `<tr><td colspan="5" class="empty">还没有提现申请。</td></tr>`}
</tbody>
</table>
</div>
<div class="stack">
<form class="panel" id="balance-form">
<h2>可用积分</h2>
<p class="sub">已入账积分减去待处理和已完成的提现。</p>
<label>钱包
<input name="wallet_address" list="wallets" required placeholder="0x..." />
</label>
<div class="row-actions" style="margin-top:14px">
<button class="gold" type="submit">查询</button>
</div>
<p class="sub" id="balance-result"></p>
${walletList()}
</form>
<form class="panel" id="withdraw-form">
<h2>申请提现</h2>
<p class="sub">提交后金额会被占用,确认时再写入链上哈希。</p>
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>金额<input name="amount" required placeholder="1.25" /></label>
<label>资产<input name="asset_symbol" value="POINT" /></label>
<button class="gold" type="submit">提交申请</button>
</div>
</form>
</div>
</section>
`;
}
function metric(label, value, note) {
return `<article class="metric"><span>${esc(label)}</span><strong>${esc(value ?? "0")}</strong><em>${esc(note || "")}</em></article>`;
}
function userForm() {
return `
<form class="panel" id="user-form">
<h2>登记钱包</h2>
<p class="sub">一个钱包可以绑定多台节点。</p>
<label>钱包地址<input name="wallet_address" required minlength="4" placeholder="0xabc123" /></label>
<div class="row-actions" style="margin-top:14px"><button class="gold" type="submit">创建用户</button></div>
</form>
`;
}
function nodeForm() {
return `
<form class="panel" id="node-form">
<h2>登记节点</h2>
<p class="sub">授权周期 12 个月,购买金额按天释放。</p>
${walletList()}
<div class="stack">
<label>钱包<input name="wallet_address" list="wallets" required /></label>
<label>序列号<input name="sn" required minlength="4" placeholder="NODE-001" /></label>
<label>购买金额<input name="purchase_amount" required placeholder="365" /></label>
<label>回调地址<input name="callback_url" placeholder="https:// 可选" /></label>
<button class="gold" type="submit">创建节点</button>
</div>
</form>
`;
}
function walletList() {
const options = state.users.map((user) => `<option value="${esc(user.wallet_address)}"></option>`).join("");
return `<datalist id="wallets">${options}</datalist>`;
}
function nodeRow(node) {
return `<tr class="clickable" role="button" tabindex="0" data-sn="${esc(node.sn)}">
<td class="mono">${esc(node.sn)}</td>
<td class="mono">${esc(short(node.wallet_address))}</td>
<td>${esc(money(node.purchase_amount))}</td>
<td>${pill(node.status)}</td>
<td>${esc(day(node.license_expires_at))}</td>
</tr>`;
}
function yieldRow(row) {
return `<tr>
<td class="mono">${esc(row.yield_date)}</td>
<td>${esc(money(row.points_delta))}</td>
<td>${esc(money(row.compute_output))}</td>
<td>${esc(pct(row.online_rate))}</td>
<td>${esc(pct(row.task_response_rate))}</td>
<td>${esc(pct(row.performance))}</td>
<td>${pill(row.status)}</td>
</tr>`;
}
function cashRow(row) {
const actions = row.status === "pending"
? `<div class="row-actions">
<input data-hash="${row.id}" placeholder="交易哈希" />
<button class="texty" data-confirm="${row.id}" type="button">确认</button>
<button class="texty" data-reject="${row.id}" type="button">拒绝</button>
</div>`
: `<span class="mono">${esc(row.tx_hash || "—")}</span>`;
return `<tr>
<td class="mono">#${row.id}</td>
<td class="mono">${esc(short(row.wallet_address))}</td>
<td>${esc(money(row.amount))} ${esc(row.asset_symbol)}</td>
<td>${pill(row.status)}</td>
<td>${actions}</td>
</tr>`;
}
function pill(status) {
const label = {
online: "在线",
offline: "离线",
pending: "待处理",
completed: "已完成",
rejected: "已拒绝",
failed: "失败",
settled: "已入账",
void: "作废",
}[status] || status;
return `<span class="pill ${esc(status)}"><i></i>${esc(label)}</span>`;
}
function bind() {
const userFormEl = document.querySelector("#user-form");
const nodeFormEl = document.querySelector("#node-form");
const balanceForm = document.querySelector("#balance-form");
const withdrawForm = document.querySelector("#withdraw-form");
if (userFormEl) userFormEl.addEventListener("submit", onCreateUser);
if (nodeFormEl) nodeFormEl.addEventListener("submit", onCreateNode);
if (balanceForm) balanceForm.addEventListener("submit", onBalance);
if (withdrawForm) withdrawForm.addEventListener("submit", onWithdraw);
document.querySelectorAll("tr.clickable").forEach((row) => row.addEventListener("click", () => openNode(row.dataset.sn)));
const ledgerNode = document.querySelector("#ledger-node");
if (ledgerNode) {
ledgerNode.addEventListener("change", async () => {
state.selected = ledgerNode.value;
state.yields = state.selected ? await api(`/nodes/${encodeURIComponent(state.selected)}/yields`) : [];
render();
});
}
const settle = document.querySelector("#settle");
if (settle) settle.addEventListener("click", onSettle);
document.querySelectorAll("[data-confirm]").forEach((button) => button.addEventListener("click", () => onConfirm(button.dataset.confirm)));
document.querySelectorAll("[data-reject]").forEach((button) => button.addEventListener("click", () => onReject(button.dataset.reject)));
}
async function onCreateUser(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
await run(() => api("/users", { method: "POST", body: JSON.stringify(body) }), "钱包已登记");
}
async function onCreateNode(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
if (!body.callback_url) delete body.callback_url;
await run(() => api("/nodes", { method: "POST", body: JSON.stringify(body) }), "节点已登记");
}
async function onBalance(event) {
event.preventDefault();
const wallet = new FormData(event.target).get("wallet_address");
const data = await api(`/users/${encodeURIComponent(wallet)}/balance`);
document.querySelector("#balance-result").textContent = `${data.wallet_address} 可用 ${money(data.available_points)}`;
}
async function onWithdraw(event) {
event.preventDefault();
const body = Object.fromEntries(new FormData(event.target));
await run(() => api("/withdrawals", { method: "POST", body: JSON.stringify(body) }), "提现申请已提交");
}
async function onSettle() {
try {
const date = document.querySelector("#settle-date").value;
const query = date ? `?yield_date=${date}` : "";
const result = await api(`/yield/settle${query}`, { method: "POST" });
showToast(`已清算 ${result.yield_date},新增 ${result.created} 条,积分 ${money(result.total_points)}`);
await load();
} catch (error) {
showError(error);
}
}
async function onConfirm(id) {
const hash = document.querySelector(`[data-hash="${id}"]`).value.trim();
if (!hash) return showToast("请先填写交易哈希");
await run(() => api(`/withdrawals/${id}/confirm`, { method: "POST", body: JSON.stringify({ tx_hash: hash }) }), "提现已确认");
}
async function onReject(id) {
await run(() => api(`/withdrawals/${id}/reject`, { method: "POST", body: JSON.stringify({ note: "控制台拒绝" }) }), "提现已拒绝");
}
async function openNode(sn) {
const node = state.nodes.find((item) => item.sn === sn);
if (!node) return;
let yields = [];
try {
yields = await api(`/nodes/${encodeURIComponent(sn)}/yields`);
} catch (error) {
showError(error);
}
const mask = document.createElement("div");
mask.className = "mask";
mask.innerHTML = `
<aside class="drawer">
<header>
<div>
<p class="eyebrow">节点</p>
<h2>${esc(node.sn)}</h2>
</div>
<button class="ghost" type="button" data-close>关闭</button>
</header>
<div style="margin-top:16px">${pill(node.status)}</div>
<div class="kv">
<span>钱包</span><b class="mono">${esc(node.wallet_address)}</b>
<span>购买</span><b>${esc(money(node.purchase_amount))}</b>
<span>开始</span><b>${esc(when(node.licensed_at))}</b>
<span>到期</span><b>${esc(when(node.license_expires_at))}</b>
<span>心跳</span><b>${esc(when(node.last_heartbeat_at))}</b>
</div>
<button class="gold" type="button" id="beat">发送心跳</button>
<h2 style="margin-top:28px">最近流水</h2>
<div class="table-wrap" style="margin-top:12px;box-shadow:none">
<table>
<thead><tr><th>日期</th><th>积分</th><th>在线率</th></tr></thead>
<tbody>${yields.slice(0, 6).map((row) => `<tr><td class="mono">${esc(row.yield_date)}</td><td>${esc(money(row.points_delta))}</td><td>${esc(pct(row.online_rate))}</td></tr>`).join("") || `<tr><td colspan="3" class="empty">暂无流水</td></tr>`}</tbody>
</table>
</div>
</aside>
`;
mask.addEventListener("click", (event) => {
if (event.target === mask || event.target.closest("[data-close]")) mask.remove();
});
mask.querySelector("#beat").addEventListener("click", async () => {
await run(() => api("/node/heartbeat", { method: "POST", body: JSON.stringify({ sn }) }), "心跳已记录");
mask.remove();
});
document.body.appendChild(mask);
}
async function run(action, message) {
try {
await action();
showToast(message);
await load();
} catch (error) {
showError(error);
}
}
async function api(path, options = {}) {
const res = await fetch(`/api/v1${path}`, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
});
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error(errorText(data));
return data;
}
function errorText(data) {
const detail = data && data.detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail)) return detail.map((item) => item.msg).join(";");
return "请求失败";
}
function showToast(message) {
toastEl.textContent = message;
toastEl.classList.add("show");
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => toastEl.classList.remove("show"), 2600);
}
function showError(error) {
showToast(error.message || "操作失败");
}
function money(value) {
if (value == null || value === "") return "0";
const raw = String(value);
const neg = raw.startsWith("-");
const [whole, frac = ""] = raw.replace("-", "").split(".");
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const trimmed = frac.replace(/0+$/, "");
return `${neg ? "-" : ""}${trimmed ? `${grouped}.${trimmed}` : grouped}`;
}
function pct(value) {
const number = Number(value);
if (!Number.isFinite(number)) return "—";
return `${(number * 100).toFixed(1)}%`;
}
function when(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", {
timeZone: "Asia/Shanghai",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function day(value) {
if (!value) return "—";
return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(value));
}
function short(value) {
const text = String(value || "");
return text.length > 16 ? `${text.slice(0, 8)}…${text.slice(-4)}` : text;
}
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[char]));
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -47
View File
@@ -7,54 +7,10 @@
<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" />
<link rel="stylesheet" href="/static/styles.css?v=2" />
<script type="module" crossorigin src="/assets/index-CZ0L8T-X.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-mWM9aOGS.css">
</head>
<body>
<div class="shell">
<aside class="rail">
<a class="brand" href="#overview">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28">
<path d="M16 3 L27 9.5 V22.5 L16 29 L5 22.5 V9.5 Z" fill="none" stroke="currentColor" stroke-width="1.4"/>
<circle cx="16" cy="16" r="2.2" fill="currentColor"/>
<path d="M16 8 V14 M16 18 V24 M10 12.5 L14.2 15 M17.8 17 L22 19.5" stroke="currentColor" stroke-width="1.2"/>
</svg>
</span>
<span>
<strong>HyperNode</strong>
<em>Compute Atelier</em>
</span>
</a>
<nav class="nav" id="nav">
<button type="button" data-view="overview">总览</button>
<button type="button" data-view="nodes">节点</button>
<button type="button" data-view="ledger">流水</button>
<button type="button" data-view="cash">提现</button>
</nav>
<div class="rail-foot">
<span id="health-dot" class="dot"></span>
<span id="health-text">正在连接</span>
</div>
</aside>
<div class="stage">
<header class="top">
<div>
<p class="eyebrow" id="eyebrow">控制面</p>
<h1 id="title">总览</h1>
</div>
<div class="top-meta">
<div class="clock">
<span>北京时间</span>
<strong id="clock">--:--:--</strong>
</div>
<button type="button" class="ghost" id="refresh">刷新</button>
</div>
</header>
<main id="view" class="view"></main>
</div>
</div>
<div id="toast" class="toast" role="status"></div>
<script src="/static/app.js?v=2"></script>
<div id="app"></div>
</body>
</html>
-359
View File
@@ -1,359 +0,0 @@
:root {
--bg: #08090d;
--ink: #f4efe6;
--muted: #a3988a;
--gold: #e0c07a;
--gold-deep: #a88345;
--line: rgba(224, 192, 122, 0.18);
--panel: rgba(16, 18, 24, 0.82);
--panel-solid: #12141b;
--online: #8ed0b5;
--offline: #d08a78;
--danger: #e09888;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
--serif: "Fraunces", "Palatino Linotype", Palatino, serif;
--sans: "Manrope", "Segoe UI", sans-serif;
--mono: "IBM Plex Mono", ui-monospace, monospace;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
color: var(--ink);
font-family: var(--sans);
background:
radial-gradient(980px 520px at 0% -10%, rgba(224, 192, 122, 0.16), transparent 58%),
radial-gradient(760px 480px at 100% 0%, rgba(126, 164, 196, 0.12), transparent 52%),
var(--bg);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(244, 239, 230, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(244, 239, 230, 0.035) 1px, transparent 1px);
background-size: 84px 84px;
mask-image: radial-gradient(circle at 20% 10%, #000 0%, transparent 72%);
}
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
h1, h2, p { margin: 0; }
.shell {
min-height: 100%;
display: grid;
grid-template-columns: 248px 1fr;
}
.rail {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 20px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--line);
background: rgba(8, 9, 13, 0.72);
backdrop-filter: blur(16px);
}
.brand {
display: flex;
gap: 12px;
align-items: center;
color: inherit;
text-decoration: none;
}
.mark {
width: 44px;
height: 44px;
display: grid;
place-items: center;
color: var(--gold);
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(180deg, rgba(224, 192, 122, 0.16), transparent);
}
.brand strong {
display: block;
font-family: var(--serif);
font-size: 22px;
font-weight: 560;
letter-spacing: -0.03em;
}
.brand em {
display: block;
margin-top: 2px;
color: var(--muted);
font-style: normal;
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.nav {
display: grid;
gap: 6px;
margin-top: 36px;
}
.nav button {
text-align: left;
border: 0;
background: transparent;
color: var(--muted);
border-radius: 12px;
padding: 12px 14px;
letter-spacing: 0.04em;
}
.nav button.active,
.nav button:hover {
color: var(--ink);
background: rgba(224, 192, 122, 0.08);
}
.nav button.active { box-shadow: inset 2px 0 0 var(--gold); }
.rail-foot {
margin-top: auto;
display: flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.dot.on {
background: var(--online);
box-shadow: 0 0 0 4px rgba(142, 208, 181, 0.15);
}
.stage { min-width: 0; padding: 32px 40px 56px; }
.top {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-end;
margin-bottom: 28px;
}
.eyebrow {
color: var(--gold);
font-size: 12px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
h1 {
margin-top: 6px;
font-family: var(--serif);
font-size: 48px;
font-weight: 560;
letter-spacing: -0.04em;
}
.top-meta { display: flex; align-items: center; gap: 16px; }
.clock {
text-align: right;
}
.clock span {
display: block;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.14em;
}
.clock strong {
font-family: var(--mono);
font-size: 20px;
font-weight: 500;
}
.ghost, .gold, .texty {
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
padding: 10px 16px;
}
.ghost:hover, .texty:hover { border-color: var(--gold); }
.gold {
border-color: transparent;
background: linear-gradient(180deg, #f0d7a2, #c4964e);
color: #24180b;
font-weight: 700;
}
.gold:hover { filter: brightness(1.05); }
.texty { padding: 6px 10px; color: var(--gold); }
.metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.metric, .panel, .table-wrap, .drawer {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: var(--shadow);
}
.metric { padding: 22px 22px 18px; }
.metric span {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 14px;
font-family: var(--serif);
font-size: 40px;
font-weight: 560;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
}
.metric em {
display: block;
margin-top: 8px;
color: var(--muted);
font-style: normal;
font-size: 13px;
}
.layout, .forms {
display: grid;
gap: 16px;
margin-top: 16px;
align-items: start;
}
.layout { grid-template-columns: 1.4fr 0.8fr; }
.forms { grid-template-columns: 1fr 1fr; }
.panel { padding: 22px; }
.panel h2 {
font-family: var(--serif);
font-size: 28px;
font-weight: 560;
letter-spacing: -0.03em;
}
.sub {
margin: 6px 0 18px;
color: var(--muted);
font-size: 14px;
}
.stack { display: grid; gap: 14px; }
label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); }
input, select {
width: 100%;
border: 1px solid var(--line);
background: rgba(8, 9, 13, 0.55);
border-radius: 12px;
padding: 12px 14px;
outline: none;
}
input:focus, select:focus { border-color: var(--gold); }
.row-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 14px 16px; text-align: left; border-bottom: 1px solid rgba(224, 192, 122, 0.08); font-size: 14px; }
th {
color: var(--muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
td input { width: 148px; padding: 8px 10px; }
tr.clickable { cursor: pointer; }
tr.clickable:hover td { background: rgba(224, 192, 122, 0.04); }
.mono { font-family: var(--mono); font-size: 13px; }
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid var(--line);
font-size: 12px;
}
.pill i {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.pill.online { color: var(--online); }
.pill.offline, .pill.rejected, .pill.failed { color: var(--offline); }
.pill.pending { color: var(--gold); }
.pill.completed, .pill.settled { color: var(--online); }
.empty {
padding: 48px 20px;
text-align: center;
color: var(--muted);
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: end;
margin-bottom: 16px;
}
.toolbar .grow { min-width: 240px; flex: 1; }
.mask {
position: fixed;
inset: 0;
background: rgba(5, 6, 8, 0.62);
display: grid;
justify-items: end;
z-index: 5;
}
.drawer {
width: min(460px, 100%);
height: 100%;
border-radius: 0;
border-right: 0;
padding: 28px;
overflow: auto;
background: #101218;
}
.drawer header { display: flex; justify-content: space-between; align-items: start; gap: 12px; }
.kv { display: grid; grid-template-columns: 88px 1fr; gap: 8px 12px; margin: 22px 0; font-size: 14px; }
.kv span { color: var(--muted); }
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(12px);
opacity: 0;
pointer-events: none;
background: #1b1e27;
border: 1px solid var(--line);
color: var(--ink);
padding: 12px 16px;
border-radius: 999px;
transition: 0.2s ease;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
@media (max-width: 1100px) {
.metrics, .forms { grid-template-columns: 1fr 1fr; }
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
.rail {
position: relative;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--line);
}
.nav { grid-template-columns: repeat(4, 1fr); margin-top: 18px; }
.rail-foot { display: none; }
.stage { padding: 22px 16px 40px; }
h1 { font-size: 36px; }
.metrics, .layout, .forms { grid-template-columns: 1fr; }
.top { align-items: start; }
}
+40 -5
View File
@@ -23,14 +23,15 @@ python -m app
| `app/jobs/daily_yield.py` | 凌晨清算入口 |
| `app/scheduler.py` | 进程内定时任务 |
| `app/settle.py` | 可单独执行的清算命令 |
| `web/` | 控制台页面,服务启动后打开 `/` |
| `frontend/` | Vue 3 控制台源码。改完后在该目录执行 `npm run build` |
| `web/` | 控制台构建结果,服务启动后打开 `/` |
| `deploy/manage.sh` | Ubuntu 22.04 交互式部署,支持 `curl \| bash` |
## 数据表
业务表:
- `users`:钱包地址、注册时间。
- `users`:钱包地址、可选用户名和密码、角色 `user` / `admin`、注册时间。
- `compute_nodes`:节点 SN、购买金额、12 个月授权、`online` / `offline`、最后心跳。可选 `callback_url` 用来推送任务。
- `node_yields`:每日算力产出和积分。同一节点同一天只有一条。
- `withdrawal_logs`:提现金额、资产符号、链上哈希、状态。
@@ -66,14 +67,43 @@ python -m app
回传必须带上任务里的 `nonce`。超时、重复回传、nonce 不匹配都不算成功。
## 登录
普通用户可以注册钱包、用户名和密码。之后用钱包地址,或用户名加密码登录。管理员只能用账号密码。
新部署会在安装结束时打印管理员账号 `admin` 和随机密码,并写入 `/opt/hypernode/.env` 的 `ADMIN_PASSWORD`。已经部署过的机器如果 `.env` 里没有这两项,服务第一次启动会在日志里写一次初始密码:
```bash
journalctl -u hypernode.service | grep 初始密码
```
页面路由:
- `/login` 登录和注册
- `/dashboard` 用户自己的节点、今日预计积分、提现申请
- `/admin` 管理员看总用户、总节点、总产出、节点在线率和提现审核
节点心跳、拉任务、回传任务不需要登录。查看全网数据、清算、确认提现需要管理员令牌。
## 接口
前缀都是 `/api/v1`。金额在 JSON 里是字符串。
前缀都是 `/api/v1`。金额在 JSON 里是字符串。浏览器登录后带 `Authorization: Bearer <token>`。
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | `/users` | 注册钱包 |
| POST | `/nodes` | 登记节点 |
| POST | `/auth/register` | 注册钱包,可带用户名和密码,返回令牌 |
| POST | `/auth/login` | 钱包地址,或用户名加密码 |
| GET | `/me/dashboard` | 当前用户的节点、积分和提现 |
| POST | `/me/nodes` | 给当前钱包登记节点 |
| POST | `/me/withdrawals` | 当前用户申请提现 |
| GET | `/admin/summary` | 管理员:用户数、节点数、总产出 |
| GET | `/admin/users` | 管理员:全网用户 |
| GET | `/admin/nodes` | 管理员:节点状态与今日在线率 |
| GET | `/admin/withdrawals` | 管理员:提现审核列表 |
| POST | `/admin/withdrawals/{id}/confirm` | 管理员:写入链上哈希并完成 |
| POST | `/admin/withdrawals/{id}/reject` | 管理员:拒绝并释放占用的积分 |
| POST | `/users` | 只登记钱包,不设置密码 |
| POST | `/nodes` | 登记节点,必须是该钱包本人或管理员 |
| GET | `/nodes/{sn}` | 查看节点 |
| POST | `/node/heartbeat` | 心跳,请求体 `{"sn":"..."}` |
| GET | `/node/{sn}/tasks` | 拉取未完成任务 |
@@ -107,5 +137,10 @@ python -m app
| `YIELD_HOUR` / `YIELD_MINUTE` | `0` / `5` | 清算时刻 |
| `LICENSE_MONTHS` | `12` | 新节点授权月数 |
| `DISABLE_SCHEDULER` | `false` | 设为 `true` 时不启动定时任务 |
| `JWT_SECRET` | 空则自动生成并写入数据库 | 登录令牌签名 |
| `JWT_EXPIRE_MINUTES` | `1440` | 令牌有效分钟数 |
| `ADMIN_USERNAME` | `admin` | 初始管理员用户名 |
| `ADMIN_PASSWORD` | 空则首次启动随机生成并写入日志 | 初始管理员密码 |
| `ADMIN_WALLET` | `admin-root` | 初始管理员钱包地址 |
修改 `.env` 后执行 `systemctl restart hypernode`。
+1 -1
View File
@@ -60,7 +60,7 @@ journalctl -u hypernode.service -f
curl http://127.0.0.1:33333/health
```
控制台:`http://<服务器 IP>:33333/`
控制台:`http://<服务器 IP>:33333/`。首次部署结束时会打印管理员账号和密码;已经部署过的机器,密码在第一次更新后的服务日志里,搜索「初始密码」。
接口文档:`http://<服务器 IP>:33333/docs`