零 Node 部署、超级管理员,并完善本地构建发布文档。
- FastAPI 单进程托管 frontend/dist,systemd 替代 PM2 - 超级管理员 admin、注册开关与用户管理 - README/DEPLOY/USAGE 说明:改代码须本地构建 dist 后 push,服务器 update.sh - 提交 frontend/dist 与 build-frontend 脚本
This commit is contained in:
@@ -12,7 +12,11 @@ class Settings(BaseSettings):
|
||||
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
||||
OLLAMA_MODEL: str = "qwen2.5:7b"
|
||||
FLUCTUATION_THRESHOLD: float = 0.08
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000,http://localhost"
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:23566,http://localhost"
|
||||
WEB_PORT: int = 23566
|
||||
FRONTEND_DIST: str = "../frontend/dist"
|
||||
ADMIN_DEFAULT_USERNAME: str = "admin"
|
||||
ADMIN_DEFAULT_PASSWORD: str = "admin123"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
@@ -32,3 +32,9 @@ def get_current_user(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
return user
|
||||
|
||||
|
||||
def get_superuser(current_user: User = Depends(get_current_user)) -> User:
|
||||
if not current_user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要超级管理员权限")
|
||||
return current_user
|
||||
|
||||
+36
-3
@@ -1,14 +1,26 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base, SessionLocal, engine
|
||||
from app.routers import auth, exams, export, students, subjects, wrong_questions
|
||||
from app.routers import admin, auth, exams, export, settings as settings_router, students, subjects, wrong_questions
|
||||
from app.services.migrate import run_migrations
|
||||
from app.services.seed import seed_subjects
|
||||
from app.services.seed import seed_admin_and_settings, seed_subjects
|
||||
|
||||
|
||||
def resolve_frontend_dist() -> Path | None:
|
||||
backend_dir = Path(__file__).resolve().parent.parent
|
||||
dist = Path(settings.FRONTEND_DIST)
|
||||
if not dist.is_absolute():
|
||||
dist = (backend_dir / dist).resolve()
|
||||
if dist.is_dir() and (dist / "index.html").is_file():
|
||||
return dist
|
||||
return None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -19,6 +31,7 @@ async def lifespan(app: FastAPI):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
seed_subjects(db)
|
||||
seed_admin_and_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
@@ -36,6 +49,8 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(settings_router.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
app.include_router(students.router, prefix="/api")
|
||||
app.include_router(subjects.router, prefix="/api")
|
||||
app.include_router(exams.router, prefix="/api")
|
||||
@@ -46,3 +61,21 @@ app.include_router(export.router, prefix="/api")
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
dist_dir = resolve_frontend_dist()
|
||||
if dist_dir is not None:
|
||||
assets_dir = dist_dir / "assets"
|
||||
if assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
|
||||
@app.get("/{full_path:path}", include_in_schema=False)
|
||||
async def serve_spa(full_path: str):
|
||||
if full_path.startswith("api") or full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="Not Found")
|
||||
if full_path in ("", "index.html"):
|
||||
return FileResponse(dist_dir / "index.html")
|
||||
candidate = dist_dir / full_path
|
||||
if candidate.is_file():
|
||||
return FileResponse(candidate)
|
||||
return FileResponse(dist_dir / "index.html")
|
||||
|
||||
@@ -33,6 +33,7 @@ class User(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_superuser: Mapped[bool] = mapped_column(default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
@@ -128,3 +129,13 @@ class WrongQuestion(Base):
|
||||
|
||||
student: Mapped["Student"] = relationship(back_populates="wrong_questions")
|
||||
subject: Mapped["Subject"] = relationship(back_populates="wrong_questions")
|
||||
|
||||
|
||||
class SystemSettings(Base):
|
||||
__tablename__ = "system_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
registration_enabled: Mapped[bool] = mapped_column(default=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import get_superuser
|
||||
from app.core.security import get_password_hash, verify_password
|
||||
from app.models.user import SystemSettings, User
|
||||
from app.schemas import (
|
||||
AdminProfileUpdate,
|
||||
AdminUserCreate,
|
||||
AdminUserOut,
|
||||
AdminUserPasswordUpdate,
|
||||
PublicSettingsOut,
|
||||
SystemSettingsOut,
|
||||
SystemSettingsUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/settings", response_model=SystemSettingsOut)
|
||||
def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
return get_or_create_settings(db)
|
||||
|
||||
|
||||
@router.patch("/settings", response_model=SystemSettingsOut)
|
||||
def update_settings(
|
||||
data: SystemSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
row = get_or_create_settings(db)
|
||||
if data.registration_enabled is not None:
|
||||
row.registration_enabled = data.registration_enabled
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.patch("/profile", response_model=AdminUserOut)
|
||||
def update_profile(
|
||||
data: AdminProfileUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
if not data.username and not data.password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写要修改的内容")
|
||||
|
||||
if data.password:
|
||||
if not data.current_password:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="修改密码需提供当前密码")
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前密码错误")
|
||||
|
||||
if data.username and data.username != current_user.username:
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
current_user.username = data.username
|
||||
|
||||
if data.password:
|
||||
current_user.password_hash = get_password_hash(data.password)
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserOut])
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
return db.query(User).order_by(User.created_at).all()
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
data: AdminUserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_superuser),
|
||||
):
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
user = User(username=data.username, password_hash=get_password_hash(data.password))
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserOut)
|
||||
def reset_user_password(
|
||||
user_id: uuid.UUID,
|
||||
data: AdminUserPasswordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.is_superuser and user.id != current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能修改其他超级管理员")
|
||||
user.password_hash = get_password_hash(data.password)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_superuser),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
if user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除超级管理员")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
@@ -13,14 +13,27 @@ from app.core.security import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.user import SystemSettings, User
|
||||
from app.schemas import RefreshRequest, TokenResponse, UserLogin, UserOut, UserRegister
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def register(data: UserRegister, db: Session = Depends(get_db)):
|
||||
settings_row = get_or_create_settings(db)
|
||||
if not settings_row.registration_enabled:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="注册已关闭,请联系管理员")
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名已存在")
|
||||
user = User(username=data.username, password_hash=get_password_hash(data.password))
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.user import SystemSettings
|
||||
from app.schemas import PublicSettingsOut
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
def get_or_create_settings(db: Session) -> SystemSettings:
|
||||
row = db.get(SystemSettings, 1)
|
||||
if row is None:
|
||||
row = SystemSettings(id=1, registration_enabled=True)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicSettingsOut)
|
||||
def public_settings(db: Session = Depends(get_db)):
|
||||
row = get_or_create_settings(db)
|
||||
return PublicSettingsOut(registration_enabled=row.registration_enabled)
|
||||
@@ -46,6 +46,46 @@ class RefreshRequest(BaseModel):
|
||||
class UserOut(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
is_superuser: bool = False
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PublicSettingsOut(BaseModel):
|
||||
registration_enabled: bool
|
||||
|
||||
|
||||
class SystemSettingsOut(BaseModel):
|
||||
registration_enabled: bool
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
registration_enabled: bool | None = None
|
||||
|
||||
|
||||
class AdminProfileUpdate(BaseModel):
|
||||
username: str | None = Field(default=None, min_length=3, max_length=64)
|
||||
current_password: str | None = None
|
||||
password: str | None = Field(default=None, min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserPasswordUpdate(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class AdminUserOut(BaseModel):
|
||||
id: UUID
|
||||
username: str
|
||||
is_superuser: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine
|
||||
|
||||
|
||||
@@ -18,3 +19,17 @@ def run_migrations() -> None:
|
||||
"NOT NULL DEFAULT 'junior_high'"
|
||||
)
|
||||
)
|
||||
|
||||
if "users" in inspector.get_table_names():
|
||||
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "is_superuser" not in user_columns:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text("ALTER TABLE users ADD COLUMN is_superuser BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
f"UPDATE users SET is_superuser = TRUE "
|
||||
f"WHERE username = '{settings.ADMIN_DEFAULT_USERNAME}'"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.user import Subject
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.user import Subject, SystemSettings, User
|
||||
|
||||
DEFAULT_SUBJECTS = [
|
||||
"语文",
|
||||
@@ -21,3 +23,22 @@ def seed_subjects(db: Session) -> None:
|
||||
if name not in existing:
|
||||
db.add(Subject(name=name))
|
||||
db.commit()
|
||||
|
||||
|
||||
def seed_admin_and_settings(db: Session) -> None:
|
||||
if db.get(SystemSettings, 1) is None:
|
||||
db.add(SystemSettings(id=1, registration_enabled=True))
|
||||
|
||||
admin = db.query(User).filter(User.username == settings.ADMIN_DEFAULT_USERNAME).first()
|
||||
if admin is None:
|
||||
db.add(
|
||||
User(
|
||||
username=settings.ADMIN_DEFAULT_USERNAME,
|
||||
password_hash=get_password_hash(settings.ADMIN_DEFAULT_PASSWORD),
|
||||
is_superuser=True,
|
||||
)
|
||||
)
|
||||
elif not admin.is_superuser:
|
||||
admin.is_superuser = True
|
||||
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user