first commit
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend package marker.
|
||||
54
backend/auth.py
Normal file
54
backend/auth.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import importlib.util
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
_jose_spec = importlib.util.find_spec("jose")
|
||||
_jose_origin = _jose_spec.origin if _jose_spec else None
|
||||
|
||||
_incompatible_jose = bool(_jose_origin and _jose_origin.endswith("site-packages/jose.py"))
|
||||
|
||||
try:
|
||||
import jwt
|
||||
from jwt import InvalidTokenError as JWTError
|
||||
except Exception as exc:
|
||||
if not _incompatible_jose:
|
||||
try:
|
||||
from jose import JWTError, jwt
|
||||
except Exception as fallback_exc:
|
||||
raise RuntimeError(
|
||||
"JWT 依赖不可用,请安装 python-jose[cryptography] 或 PyJWT"
|
||||
) from fallback_exc
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"检测到不兼容依赖 jose,请卸载 jose 后安装 python-jose[cryptography] 或安装 PyJWT"
|
||||
) from exc
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def verify_password(username: str, password: str) -> bool:
|
||||
return username == settings.admin_username and password == settings.admin_password
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> str:
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
|
||||
username: str | None = payload.get("sub")
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效Token")
|
||||
return username
|
||||
except JWTError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期") from exc
|
||||
32
backend/config.py
Normal file
32
backend/config.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
api_key: str = ""
|
||||
model_name: str = "gpt-4.1"
|
||||
dmxapi_url: str = "https://www.dmxapi.cn/v1/responses"
|
||||
jwt_secret_key: str = "change-me-in-env"
|
||||
jwt_algorithm: str = "HS256"
|
||||
access_token_expire_minutes: int = 60 * 12
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
database_url: str = f"sqlite:///{Path(__file__).resolve().parent / 'problem_bank.db'}"
|
||||
upload_dir: str = str(Path(__file__).resolve().parent / "uploads")
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
|
||||
@property
|
||||
def type_map(self) -> Dict[str, str]:
|
||||
return {
|
||||
"单选题": "单选",
|
||||
"多选题": "多选",
|
||||
"不定项选择题": "不定项",
|
||||
"填空题": "填空",
|
||||
"解答题": "解答",
|
||||
}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
23
backend/database.py
Normal file
23
backend/database.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args={"check_same_thread": False} if settings.database_url.startswith("sqlite") else {},
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
30
backend/main.py
Normal file
30
backend/main.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from backend.database import Base, engine
|
||||
from backend.routers import auth, categories, exports, imports, practice, questions, stats
|
||||
|
||||
app = FastAPI(title="Problem Bank API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(questions.router, prefix="/api/questions", tags=["questions"])
|
||||
app.include_router(imports.router, prefix="/api/import", tags=["imports"])
|
||||
app.include_router(exports.router, prefix="/api/export", tags=["exports"])
|
||||
app.include_router(categories.router, prefix="/api/categories", tags=["categories"])
|
||||
app.include_router(practice.router, prefix="/api/practice", tags=["practice"])
|
||||
app.include_router(stats.router, prefix="/api/stats", tags=["stats"])
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
54
backend/models.py
Normal file
54
backend/models.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Question(Base):
|
||||
__tablename__ = "questions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
chapter: Mapped[str] = mapped_column(Text, default="")
|
||||
primary_knowledge: Mapped[str] = mapped_column(Text, default="")
|
||||
secondary_knowledge: Mapped[str] = mapped_column(Text, default="")
|
||||
question_type: Mapped[str] = mapped_column(Text, default="")
|
||||
difficulty: Mapped[str] = mapped_column(Text, default="")
|
||||
stem: Mapped[str] = mapped_column(Text)
|
||||
option_a: Mapped[str] = mapped_column(Text, default="")
|
||||
option_b: Mapped[str] = mapped_column(Text, default="")
|
||||
option_c: Mapped[str] = mapped_column(Text, default="")
|
||||
option_d: Mapped[str] = mapped_column(Text, default="")
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
explanation: Mapped[str] = mapped_column(Text, default="")
|
||||
notes: Mapped[str] = mapped_column(Text, default="")
|
||||
source_file: Mapped[str] = mapped_column(Text, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
|
||||
)
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
parent_id: Mapped[int | None] = mapped_column(ForeignKey("categories.id"), nullable=True)
|
||||
level: Mapped[int] = mapped_column(Integer, default=1)
|
||||
|
||||
children: Mapped[list["Category"]] = relationship(
|
||||
"Category", backref="parent", remote_side=[id], lazy="joined"
|
||||
)
|
||||
|
||||
|
||||
class ImportHistory(Base):
|
||||
__tablename__ = "import_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
filename: Mapped[str] = mapped_column(Text, default="")
|
||||
method: Mapped[str] = mapped_column(Text, default="manual")
|
||||
question_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
status: Mapped[str] = mapped_column(Text, default="success")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
12
backend/requirements.txt
Normal file
12
backend/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlalchemy
|
||||
pydantic
|
||||
pydantic-settings
|
||||
python-jose[cryptography]
|
||||
PyJWT
|
||||
passlib[bcrypt]
|
||||
python-multipart
|
||||
requests
|
||||
openpyxl
|
||||
python-dotenv
|
||||
1
backend/routers/__init__.py
Normal file
1
backend/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package marker.
|
||||
13
backend/routers/auth.py
Normal file
13
backend/routers/auth.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from backend.auth import create_access_token, verify_password
|
||||
from backend.schemas import LoginRequest, Token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(payload: LoginRequest) -> Token:
|
||||
if not verify_password(payload.username, payload.password):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
return Token(access_token=create_access_token(payload.username))
|
||||
74
backend/routers/categories.py
Normal file
74
backend/routers/categories.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import Category, Question
|
||||
from backend.schemas import CategoryCreate, CategoryOut
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
def _build_tree(rows: list[Category], counts: dict[str, int]) -> list[dict]:
|
||||
nodes = {
|
||||
row.id: {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"level": row.level,
|
||||
"parent_id": row.parent_id,
|
||||
"count": counts.get(row.name, 0),
|
||||
"children": [],
|
||||
}
|
||||
for row in rows
|
||||
}
|
||||
tree = []
|
||||
for node in nodes.values():
|
||||
pid = node["parent_id"]
|
||||
if pid and pid in nodes:
|
||||
nodes[pid]["children"].append(node)
|
||||
else:
|
||||
tree.append(node)
|
||||
return tree
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_categories(db: Session = Depends(get_db)) -> dict:
|
||||
rows = db.query(Category).order_by(Category.level.asc(), Category.id.asc()).all()
|
||||
counts = dict(db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all())
|
||||
return {"items": _build_tree(rows, counts)}
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryOut)
|
||||
def create_category(payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
|
||||
if payload.parent_id:
|
||||
parent = db.get(Category, payload.parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(status_code=404, detail="父分类不存在")
|
||||
item = Category(**payload.model_dump())
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return CategoryOut.model_validate(item)
|
||||
|
||||
|
||||
@router.put("/{category_id}", response_model=CategoryOut)
|
||||
def update_category(category_id: int, payload: CategoryCreate, db: Session = Depends(get_db)) -> CategoryOut:
|
||||
item = db.get(Category, category_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
for key, value in payload.model_dump().items():
|
||||
setattr(item, key, value)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return CategoryOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{category_id}")
|
||||
def delete_category(category_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
item = db.get(Category, category_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="分类不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
68
backend/routers/exports.py
Normal file
68
backend/routers/exports.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import csv
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import Question
|
||||
from backend.services.excel_service import export_excel_bytes
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
def _filter_questions(
|
||||
db: Session, chapter: str = "", question_type: str = "", difficulty: str = ""
|
||||
) -> list[Question]:
|
||||
query = db.query(Question)
|
||||
if chapter:
|
||||
query = query.filter(Question.chapter == chapter)
|
||||
if question_type:
|
||||
query = query.filter(Question.question_type == question_type)
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
return query.order_by(Question.id.desc()).all()
|
||||
|
||||
|
||||
@router.get("")
|
||||
def export_questions(
|
||||
format: str = Query(default="json", pattern="^(json|csv|xlsx)$"),
|
||||
chapter: str = "",
|
||||
question_type: str = "",
|
||||
difficulty: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
items = _filter_questions(db, chapter, question_type, difficulty)
|
||||
rows = [
|
||||
{
|
||||
"id": i.id,
|
||||
"chapter": i.chapter,
|
||||
"primary_knowledge": i.primary_knowledge,
|
||||
"secondary_knowledge": i.secondary_knowledge,
|
||||
"question_type": i.question_type,
|
||||
"difficulty": i.difficulty,
|
||||
"stem": i.stem,
|
||||
"option_a": i.option_a,
|
||||
"option_b": i.option_b,
|
||||
"option_c": i.option_c,
|
||||
"option_d": i.option_d,
|
||||
"answer": i.answer,
|
||||
"explanation": i.explanation,
|
||||
"notes": i.notes,
|
||||
"source_file": i.source_file,
|
||||
}
|
||||
for i in items
|
||||
]
|
||||
if format == "json":
|
||||
return {"format": "json", "content": json.dumps(rows, ensure_ascii=False)}
|
||||
if format == "csv":
|
||||
out = StringIO()
|
||||
writer = csv.DictWriter(out, fieldnames=rows[0].keys() if rows else ["id"])
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
return {"format": "csv", "content": out.getvalue()}
|
||||
xlsx_bytes = export_excel_bytes(rows)
|
||||
return {"format": "xlsx", "content_base64": xlsx_bytes.hex()}
|
||||
97
backend/routers/imports.py
Normal file
97
backend/routers/imports.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import ImportHistory, Question
|
||||
from backend.schemas import ImportHistoryOut, QuestionOut
|
||||
from backend.services.excel_service import create_template_bytes, parse_excel_file
|
||||
from backend.services.file_utils import save_upload
|
||||
from backend.services.parser import DMXAPIService, extract_metadata
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.post("/ai/parse")
|
||||
def parse_by_ai(file: UploadFile = File(...)) -> dict:
|
||||
path = save_upload(file)
|
||||
parser = DMXAPIService()
|
||||
metadata = extract_metadata(file.filename or path.name)
|
||||
questions = parser.parse_file(str(path))
|
||||
preview = []
|
||||
for q in questions:
|
||||
preview.append(
|
||||
{
|
||||
"chapter": metadata["chapter"],
|
||||
"primary_knowledge": "",
|
||||
"secondary_knowledge": metadata["secondary_knowledge"],
|
||||
"question_type": metadata["question_type"],
|
||||
"difficulty": metadata["difficulty"],
|
||||
"stem": q.get("题干", ""),
|
||||
"option_a": q.get("选项A", ""),
|
||||
"option_b": q.get("选项B", ""),
|
||||
"option_c": q.get("选项C", ""),
|
||||
"option_d": q.get("选项D", ""),
|
||||
"answer": q.get("正确答案", ""),
|
||||
"explanation": q.get("解析", ""),
|
||||
"notes": q.get("备注", ""),
|
||||
"source_file": metadata["source_file"],
|
||||
}
|
||||
)
|
||||
return {"filename": file.filename, "preview": preview}
|
||||
|
||||
|
||||
@router.post("/ai/confirm", response_model=list[QuestionOut])
|
||||
def confirm_ai_import(payload: list[dict], db: Session = Depends(get_db)) -> list[QuestionOut]:
|
||||
if not payload:
|
||||
raise HTTPException(status_code=400, detail="没有可导入数据")
|
||||
items = [Question(**item) for item in payload]
|
||||
db.add_all(items)
|
||||
db.add(
|
||||
ImportHistory(
|
||||
filename=items[0].source_file if items else "",
|
||||
method="ai",
|
||||
question_count=len(items),
|
||||
status="success",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for item in items:
|
||||
db.refresh(item)
|
||||
return [QuestionOut.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.post("/excel", response_model=list[QuestionOut])
|
||||
def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)) -> list[QuestionOut]:
|
||||
path = save_upload(file)
|
||||
if Path(path).suffix.lower() not in [".xlsx", ".xlsm", ".xltx", ".xltm"]:
|
||||
raise HTTPException(status_code=400, detail="仅支持 Excel 文件")
|
||||
rows = parse_excel_file(Path(path))
|
||||
items = [Question(**row) for row in rows]
|
||||
db.add_all(items)
|
||||
db.add(
|
||||
ImportHistory(
|
||||
filename=file.filename or "",
|
||||
method="excel",
|
||||
question_count=len(items),
|
||||
status="success",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
for item in items:
|
||||
db.refresh(item)
|
||||
return [QuestionOut.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.get("/template")
|
||||
def download_template() -> dict:
|
||||
content = create_template_bytes()
|
||||
return {"filename": "question_template.xlsx", "content_base64": content.hex()}
|
||||
|
||||
|
||||
@router.get("/history", response_model=list[ImportHistoryOut])
|
||||
def import_history(db: Session = Depends(get_db)) -> list[ImportHistoryOut]:
|
||||
rows = db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(100).all()
|
||||
return [ImportHistoryOut.model_validate(r) for r in rows]
|
||||
57
backend/routers/practice.py
Normal file
57
backend/routers/practice.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import random
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import Question
|
||||
from backend.schemas import PracticeCheckRequest, PracticeStartRequest
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
def start_practice(payload: PracticeStartRequest, db: Session = Depends(get_db)) -> dict:
|
||||
query = db.query(Question)
|
||||
if payload.chapter:
|
||||
query = query.filter(Question.chapter == payload.chapter)
|
||||
if payload.secondary_knowledge:
|
||||
query = query.filter(Question.secondary_knowledge == payload.secondary_knowledge)
|
||||
if payload.question_type:
|
||||
query = query.filter(Question.question_type == payload.question_type)
|
||||
if payload.difficulty:
|
||||
query = query.filter(Question.difficulty == payload.difficulty)
|
||||
items = query.all()
|
||||
if payload.random_mode:
|
||||
random.shuffle(items)
|
||||
items = items[: payload.limit]
|
||||
return {
|
||||
"total": len(items),
|
||||
"items": [
|
||||
{
|
||||
"id": i.id,
|
||||
"stem": i.stem,
|
||||
"option_a": i.option_a,
|
||||
"option_b": i.option_b,
|
||||
"option_c": i.option_c,
|
||||
"option_d": i.option_d,
|
||||
"question_type": i.question_type,
|
||||
}
|
||||
for i in items
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/check")
|
||||
def check_answer(payload: PracticeCheckRequest, db: Session = Depends(get_db)) -> dict:
|
||||
item = db.get(Question, payload.question_id)
|
||||
if not item:
|
||||
return {"correct": False, "message": "题目不存在"}
|
||||
user_answer = payload.user_answer.strip().upper()
|
||||
right_answer = item.answer.strip().upper()
|
||||
return {
|
||||
"correct": user_answer == right_answer,
|
||||
"right_answer": right_answer,
|
||||
"explanation": item.explanation,
|
||||
}
|
||||
136
backend/routers/questions.py
Normal file
136
backend/routers/questions.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import Question
|
||||
from backend.schemas import (
|
||||
BatchDeleteRequest,
|
||||
BatchUpdateRequest,
|
||||
QuestionCreate,
|
||||
QuestionOut,
|
||||
QuestionUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_questions(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
keyword: str = "",
|
||||
chapter: str = "",
|
||||
secondary_knowledge: str = "",
|
||||
question_type: str = "",
|
||||
difficulty: str = "",
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
query = db.query(Question)
|
||||
if keyword:
|
||||
like = f"%{keyword}%"
|
||||
query = query.filter(
|
||||
or_(
|
||||
Question.stem.like(like),
|
||||
Question.option_a.like(like),
|
||||
Question.option_b.like(like),
|
||||
Question.option_c.like(like),
|
||||
Question.option_d.like(like),
|
||||
Question.explanation.like(like),
|
||||
)
|
||||
)
|
||||
if chapter:
|
||||
query = query.filter(Question.chapter == chapter)
|
||||
if secondary_knowledge:
|
||||
query = query.filter(Question.secondary_knowledge == secondary_knowledge)
|
||||
if question_type:
|
||||
query = query.filter(Question.question_type == question_type)
|
||||
if difficulty:
|
||||
query = query.filter(Question.difficulty == difficulty)
|
||||
|
||||
total = query.with_entities(func.count(Question.id)).scalar() or 0
|
||||
items = (
|
||||
query.order_by(Question.updated_at.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return {"total": total, "items": [QuestionOut.model_validate(i).model_dump() for i in items]}
|
||||
|
||||
|
||||
@router.get("/id/{question_id}", response_model=QuestionOut)
|
||||
def get_question(question_id: int, db: Session = Depends(get_db)) -> QuestionOut:
|
||||
item = db.get(Question, question_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="题目不存在")
|
||||
return QuestionOut.model_validate(item)
|
||||
|
||||
|
||||
@router.post("", response_model=QuestionOut)
|
||||
def create_question(payload: QuestionCreate, db: Session = Depends(get_db)) -> QuestionOut:
|
||||
item = Question(**payload.model_dump())
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return QuestionOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/batch")
|
||||
def batch_delete(payload: BatchDeleteRequest, db: Session = Depends(get_db)) -> dict:
|
||||
if not payload.ids:
|
||||
return {"deleted": 0}
|
||||
deleted = db.query(Question).filter(Question.id.in_(payload.ids)).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
return {"deleted": deleted}
|
||||
|
||||
|
||||
@router.put("/batch/update")
|
||||
def batch_update(payload: BatchUpdateRequest, db: Session = Depends(get_db)) -> dict:
|
||||
if not payload.ids:
|
||||
return {"updated": 0}
|
||||
updates = payload.model_dump(exclude_none=True, exclude={"ids"})
|
||||
if not updates:
|
||||
return {"updated": 0}
|
||||
updated = db.query(Question).filter(Question.id.in_(payload.ids)).update(
|
||||
updates, synchronize_session=False
|
||||
)
|
||||
db.commit()
|
||||
return {"updated": updated}
|
||||
|
||||
|
||||
@router.get("/meta/options")
|
||||
def get_filter_options(db: Session = Depends(get_db)) -> dict:
|
||||
def distinct_values(column) -> list[str]:
|
||||
rows = db.query(column).filter(column != "").distinct().all()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
return {
|
||||
"chapters": distinct_values(Question.chapter),
|
||||
"secondary_knowledge_list": distinct_values(Question.secondary_knowledge),
|
||||
"question_types": distinct_values(Question.question_type),
|
||||
"difficulties": distinct_values(Question.difficulty),
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{question_id}", response_model=QuestionOut)
|
||||
def update_question(question_id: int, payload: QuestionUpdate, db: Session = Depends(get_db)) -> QuestionOut:
|
||||
item = db.get(Question, question_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="题目不存在")
|
||||
update_data = payload.model_dump(exclude_none=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(item, key, value)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return QuestionOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{question_id}")
|
||||
def delete_question(question_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
item = db.get(Question, question_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="题目不存在")
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
37
backend/routers/stats.py
Normal file
37
backend/routers/stats.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.auth import get_current_user
|
||||
from backend.database import get_db
|
||||
from backend.models import ImportHistory, Question
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_user)])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_stats(db: Session = Depends(get_db)) -> dict:
|
||||
total = db.query(func.count(Question.id)).scalar() or 0
|
||||
by_type = db.query(Question.question_type, func.count(Question.id)).group_by(Question.question_type).all()
|
||||
by_difficulty = db.query(Question.difficulty, func.count(Question.id)).group_by(Question.difficulty).all()
|
||||
by_chapter = db.query(Question.chapter, func.count(Question.id)).group_by(Question.chapter).all()
|
||||
latest_imports = (
|
||||
db.query(ImportHistory).order_by(ImportHistory.created_at.desc()).limit(10).all()
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"by_type": [{"name": n or "未分类", "value": v} for n, v in by_type],
|
||||
"by_difficulty": [{"name": n or "未分类", "value": v} for n, v in by_difficulty],
|
||||
"by_chapter": [{"name": n or "未分类", "value": v} for n, v in by_chapter],
|
||||
"latest_imports": [
|
||||
{
|
||||
"id": i.id,
|
||||
"filename": i.filename,
|
||||
"method": i.method,
|
||||
"question_count": i.question_count,
|
||||
"status": i.status,
|
||||
"created_at": i.created_at.isoformat(),
|
||||
}
|
||||
for i in latest_imports
|
||||
],
|
||||
}
|
||||
117
backend/schemas.py
Normal file
117
backend/schemas.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class QuestionBase(BaseModel):
|
||||
chapter: str = ""
|
||||
primary_knowledge: str = ""
|
||||
secondary_knowledge: str = ""
|
||||
question_type: str = ""
|
||||
difficulty: str = ""
|
||||
stem: str = Field(..., min_length=1)
|
||||
option_a: str = ""
|
||||
option_b: str = ""
|
||||
option_c: str = ""
|
||||
option_d: str = ""
|
||||
answer: str = ""
|
||||
explanation: str = ""
|
||||
notes: str = ""
|
||||
source_file: str = ""
|
||||
|
||||
|
||||
class QuestionCreate(QuestionBase):
|
||||
pass
|
||||
|
||||
|
||||
class QuestionUpdate(BaseModel):
|
||||
chapter: Optional[str] = None
|
||||
primary_knowledge: Optional[str] = None
|
||||
secondary_knowledge: Optional[str] = None
|
||||
question_type: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
stem: Optional[str] = None
|
||||
option_a: Optional[str] = None
|
||||
option_b: Optional[str] = None
|
||||
option_c: Optional[str] = None
|
||||
option_d: Optional[str] = None
|
||||
answer: Optional[str] = None
|
||||
explanation: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
source_file: Optional[str] = None
|
||||
|
||||
|
||||
class QuestionOut(QuestionBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BatchDeleteRequest(BaseModel):
|
||||
ids: list[int]
|
||||
|
||||
|
||||
class BatchUpdateRequest(BaseModel):
|
||||
ids: list[int]
|
||||
chapter: Optional[str] = None
|
||||
primary_knowledge: Optional[str] = None
|
||||
secondary_knowledge: Optional[str] = None
|
||||
question_type: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[int] = None
|
||||
level: int = 1
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryOut(CategoryBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ImportHistoryOut(BaseModel):
|
||||
id: int
|
||||
filename: str
|
||||
method: str
|
||||
question_count: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PracticeStartRequest(BaseModel):
|
||||
chapter: Optional[str] = None
|
||||
secondary_knowledge: Optional[str] = None
|
||||
question_type: Optional[str] = None
|
||||
difficulty: Optional[str] = None
|
||||
random_mode: bool = False
|
||||
limit: int = 20
|
||||
|
||||
|
||||
class PracticeCheckRequest(BaseModel):
|
||||
question_id: int
|
||||
user_answer: str
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package marker.
|
||||
70
backend/services/excel_service.py
Normal file
70
backend/services/excel_service.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
QUESTION_COLUMNS = [
|
||||
("chapter", "章节"),
|
||||
("primary_knowledge", "一级知识点"),
|
||||
("secondary_knowledge", "二级知识点"),
|
||||
("question_type", "题目类型"),
|
||||
("difficulty", "难度"),
|
||||
("stem", "题干"),
|
||||
("option_a", "选项A"),
|
||||
("option_b", "选项B"),
|
||||
("option_c", "选项C"),
|
||||
("option_d", "选项D"),
|
||||
("answer", "正确答案"),
|
||||
("explanation", "解析"),
|
||||
("notes", "备注"),
|
||||
("source_file", "来源文件"),
|
||||
]
|
||||
|
||||
|
||||
def create_template_bytes() -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "questions"
|
||||
ws.append([col[1] for col in QUESTION_COLUMNS])
|
||||
ws.append(["函数", "", "函数性质", "单选", "中", "示例题干", "A", "B", "C", "D", "A", "示例解析", "", "template.xlsx"])
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def parse_excel_file(path: Path) -> list[dict]:
|
||||
wb = load_workbook(path, read_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return []
|
||||
headers = [str(v).strip() if v is not None else "" for v in rows[0]]
|
||||
key_map = {label: key for key, label in QUESTION_COLUMNS}
|
||||
result: list[dict] = []
|
||||
for row in rows[1:]:
|
||||
if not row or all(v is None or str(v).strip() == "" for v in row):
|
||||
continue
|
||||
item = {key: "" for key, _ in QUESTION_COLUMNS}
|
||||
for idx, val in enumerate(row):
|
||||
if idx >= len(headers):
|
||||
continue
|
||||
label = headers[idx]
|
||||
key = key_map.get(label)
|
||||
if not key:
|
||||
continue
|
||||
item[key] = "" if val is None else str(val)
|
||||
if item["stem"]:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def export_excel_bytes(items: list[dict]) -> bytes:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "questions"
|
||||
ws.append([label for _, label in QUESTION_COLUMNS])
|
||||
for item in items:
|
||||
ws.append([item.get(key, "") for key, _ in QUESTION_COLUMNS])
|
||||
buf = BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
20
backend/services/file_utils.py
Normal file
20
backend/services/file_utils.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
def ensure_upload_dir() -> Path:
|
||||
target = Path(settings.upload_dir)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
def save_upload(upload_file: UploadFile) -> Path:
|
||||
target_dir = ensure_upload_dir()
|
||||
target_path = target_dir / upload_file.filename
|
||||
with target_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(upload_file.file, buffer)
|
||||
return target_path
|
||||
167
backend/services/parser.py
Normal file
167
backend/services/parser.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
class DMXAPIService:
|
||||
def __init__(self) -> None:
|
||||
self.api_key = settings.api_key
|
||||
self.model_name = settings.model_name
|
||||
self.api_url = settings.dmxapi_url
|
||||
|
||||
def parse_file(self, file_path: str) -> list[dict]:
|
||||
path = Path(file_path)
|
||||
if path.suffix.lower() != ".pdf":
|
||||
pdf_path = self._convert_to_pdf(path)
|
||||
else:
|
||||
pdf_path = path
|
||||
|
||||
try:
|
||||
file_url = self._upload_to_temp_host(pdf_path)
|
||||
questions = self._parse_with_file_url(file_url, path.name)
|
||||
finally:
|
||||
if pdf_path != path and pdf_path.exists():
|
||||
pdf_path.unlink(missing_ok=True)
|
||||
|
||||
return questions
|
||||
|
||||
def _upload_to_temp_host(self, path: Path) -> str:
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
response = requests.post("https://file.io", files={"file": f}, timeout=60)
|
||||
if response.status_code == 200 and response.json().get("success"):
|
||||
return response.json()["link"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with path.open("rb") as f:
|
||||
response = requests.post("https://tmpfiles.org/api/v1/upload", files={"file": f}, timeout=60)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"上传失败: {response.text}")
|
||||
data = response.json()
|
||||
if data.get("status") != "success":
|
||||
raise ValueError(f"上传失败: {data}")
|
||||
return data["data"]["url"].replace("tmpfiles.org/", "tmpfiles.org/dl/")
|
||||
|
||||
def _convert_to_pdf(self, path: Path) -> Path:
|
||||
pdf_path = path.with_suffix(".pdf")
|
||||
pdf_path.unlink(missing_ok=True)
|
||||
|
||||
cmd = [
|
||||
"pandoc",
|
||||
str(path),
|
||||
"-o",
|
||||
str(pdf_path),
|
||||
"--pdf-engine=xelatex",
|
||||
"-V",
|
||||
"CJKmainfont=PingFang SC",
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=90)
|
||||
if result.returncode != 0:
|
||||
fallback = [
|
||||
"pandoc",
|
||||
str(path),
|
||||
"-o",
|
||||
str(pdf_path),
|
||||
"--pdf-engine=weasyprint",
|
||||
]
|
||||
result = subprocess.run(fallback, capture_output=True, text=True, timeout=90)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"文件转 PDF 失败: {result.stderr}")
|
||||
return pdf_path
|
||||
|
||||
def _parse_with_file_url(self, file_url: str, original_filename: str) -> list[dict]:
|
||||
if not self.api_key:
|
||||
raise ValueError("未配置 API_KEY,无法调用 DMXAPI")
|
||||
|
||||
payload = {
|
||||
"model": self.model_name,
|
||||
"input": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_file", "file_url": file_url},
|
||||
{"type": "input_text", "text": self._build_instruction(original_filename)},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
response = requests.post(
|
||||
self.api_url, headers=headers, data=json.dumps(payload), timeout=180
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise ValueError(f"DMXAPI 请求失败: {response.status_code} {response.text}")
|
||||
return self._extract_questions(response.json())
|
||||
|
||||
def _build_instruction(self, filename: str) -> str:
|
||||
return f"""请从文件\"{filename}\"中提取所有题目信息,并以JSON数组格式返回。
|
||||
|
||||
提取字段:题干、选项A、选项B、选项C、选项D、正确答案、解析、备注。
|
||||
数学公式统一转为 LaTeX,内联公式使用 $...$,独立公式使用 $$...$$。
|
||||
答案统一大写字母,清理“答案:”等前缀。
|
||||
仅返回 JSON 数组,不要返回额外说明。"""
|
||||
|
||||
def _extract_questions(self, response: dict) -> list[dict]:
|
||||
if response.get("status") != "completed":
|
||||
raise ValueError(f"响应状态异常: {response.get('status')}")
|
||||
text = None
|
||||
for item in response.get("output", []):
|
||||
if item.get("type") == "message":
|
||||
for content in item.get("content", []):
|
||||
if content.get("type") == "output_text":
|
||||
text = content.get("text")
|
||||
break
|
||||
if not text:
|
||||
raise ValueError("未在响应中找到文本内容")
|
||||
questions = self._parse_json(text)
|
||||
for q in questions:
|
||||
for field in ["选项A", "选项B", "选项C", "选项D", "解析", "备注"]:
|
||||
q.setdefault(field, "")
|
||||
return questions
|
||||
|
||||
def _parse_json(self, text: str) -> list[dict]:
|
||||
start_idx = text.find("[")
|
||||
end_idx = text.rfind("]")
|
||||
if start_idx < 0 or end_idx < 0:
|
||||
raise ValueError(f"未找到 JSON 数组: {text[:200]}")
|
||||
json_str = text[start_idx : end_idx + 1]
|
||||
data = json.loads(json_str)
|
||||
if not isinstance(data, list):
|
||||
raise ValueError("解析结果不是数组")
|
||||
for index, item in enumerate(data):
|
||||
if "题干" not in item or "正确答案" not in item:
|
||||
raise ValueError(f"第 {index + 1} 题缺少题干或正确答案")
|
||||
item["正确答案"] = re.sub(r"[^A-D0-9]", "", str(item.get("正确答案", "")).upper())
|
||||
return data
|
||||
|
||||
|
||||
def extract_metadata(filename: str) -> dict:
|
||||
basename = Path(filename).stem
|
||||
separators = ["+", " ", "-", "_"]
|
||||
parts = [basename]
|
||||
for sep in separators:
|
||||
if sep in basename:
|
||||
parts = basename.split(sep)
|
||||
break
|
||||
secondary = parts[0].strip() if len(parts) > 0 else ""
|
||||
raw_type = parts[1].strip() if len(parts) > 1 else ""
|
||||
difficulty = parts[2].strip() if len(parts) > 2 else ""
|
||||
mapped_type = settings.type_map.get(raw_type, raw_type)
|
||||
chapter = secondary.split("的")[0] if "的" in secondary else secondary
|
||||
chapter = chapter or "未分类"
|
||||
return {
|
||||
"chapter": chapter,
|
||||
"secondary_knowledge": secondary,
|
||||
"question_type": mapped_type,
|
||||
"difficulty": difficulty,
|
||||
"source_file": filename,
|
||||
}
|
||||
Reference in New Issue
Block a user