加上登录和角色权限,并用 Vue 控制台区分用户与管理员。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+172
-61
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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})
|
||||
|
||||
@@ -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):
|
||||
"""定时任务锁。锁到期后其他实例才能接手,进程崩溃不会永久占住任务。"""
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user