零 Node 部署、超级管理员,并完善本地构建发布文档。

- FastAPI 单进程托管 frontend/dist,systemd 替代 PM2

- 超级管理员 admin、注册开关与用户管理

- README/DEPLOY/USAGE 说明:改代码须本地构建 dist 后 push,服务器 update.sh

- 提交 frontend/dist 与 build-frontend 脚本
This commit is contained in:
dekun
2026-06-28 13:19:41 +08:00
parent a3d4875bde
commit f1ad4273f4
34 changed files with 1567 additions and 268 deletions
+5 -1
View File
@@ -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"
+6
View File
@@ -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
View File
@@ -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")
+11
View File
@@ -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)
)
+140
View File
@@ -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()
+14 -1
View File
@@ -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))
+24
View File
@@ -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)
+40
View File
@@ -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}
+15
View File
@@ -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}'"
)
)
+22 -1
View File
@@ -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()