加上登录和角色权限,并用 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
+172 -61
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.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],
)
@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(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)
).all()
)
return SummaryOut(
users=users,
nodes=nodes,
online=online,
offline=nodes - online,
authorized_amount=authorized,
settled_points=settled,
pending_withdrawals=pending,
)
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