Initial commit: WordLoop 单词学习应用
Vue 前端 + FastAPI 后端,含部署脚本与词典数据。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# 复制为 .env 后按需修改
|
||||
MYSQL_HOST=localhost
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_USER=boot
|
||||
MYSQL_PASSWORD=888888
|
||||
MYSQL_DATABASE=wordloop
|
||||
|
||||
WORDLOOP_SECRET_KEY=wordloop-dev-secret-change-in-production
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from database import get_db
|
||||
from models import User
|
||||
|
||||
SECRET_KEY = os.getenv("WORDLOOP_SECRET_KEY", "wordloop-dev-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(user_id: int, username: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {"sub": str(user_id), "username": username, "exp": expire}
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效或过期的登录凭证",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
user_id: Optional[str] = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
user = db.query(User).filter(User.id == int(user_id)).first()
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
return user
|
||||
@@ -0,0 +1,33 @@
|
||||
from functools import lru_cache
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
mysql_host: str = "localhost"
|
||||
mysql_port: int = 3306
|
||||
mysql_user: str = "boot"
|
||||
mysql_password: str = "888888"
|
||||
mysql_database: str = "wordloop"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
user = quote_plus(self.mysql_user)
|
||||
password = quote_plus(self.mysql_password)
|
||||
return (
|
||||
f"mysql+pymysql://{user}:{password}"
|
||||
f"@{self.mysql_host}:{self.mysql_port}/{self.mysql_database}"
|
||||
"?charset=utf8mb4"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,38 @@
|
||||
[
|
||||
{"lemma_en": "apple", "zh": "苹果", "phonetic": "/ˈæpəl/", "example_en": "I eat an apple every day.", "example_cn": "我每天吃一个苹果。"},
|
||||
{"lemma_en": "banana", "zh": "香蕉", "phonetic": "/bəˈnænə/", "example_en": "She bought a banana.", "example_cn": "她买了一根香蕉。"},
|
||||
{"lemma_en": "orange", "zh": "橙子", "phonetic": "/ˈɒrɪndʒ/", "example_en": "This orange is sweet.", "example_cn": "这个橙子很甜。"},
|
||||
{"lemma_en": "grape", "zh": "葡萄", "phonetic": "/ɡreɪp/", "example_en": "Grapes grow in bunches.", "example_cn": "葡萄成串生长。"},
|
||||
{"lemma_en": "watermelon", "zh": "西瓜", "phonetic": "/ˈwɔːtərmelən/", "example_en": "We shared a watermelon.", "example_cn": "我们分享了一个西瓜。"},
|
||||
{"lemma_en": "book", "zh": "书", "phonetic": "/bʊk/", "example_en": "I read a book before bed.", "example_cn": "我睡前读一本书。"},
|
||||
{"lemma_en": "school", "zh": "学校", "phonetic": "/skuːl/", "example_en": "Children go to school.", "example_cn": "孩子们去学校。"},
|
||||
{"lemma_en": "teacher", "zh": "老师", "phonetic": "/ˈtiːtʃər/", "example_en": "The teacher is kind.", "example_cn": "这位老师很和蔼。"},
|
||||
{"lemma_en": "student", "zh": "学生", "phonetic": "/ˈstjuːdnt/", "example_en": "Every student needs practice.", "example_cn": "每个学生都需要练习。"},
|
||||
{"lemma_en": "cat", "zh": "猫", "phonetic": "/kæt/", "example_en": "The cat is sleeping.", "example_cn": "猫在睡觉。"},
|
||||
{"lemma_en": "dog", "zh": "狗", "phonetic": "/dɒɡ/", "example_en": "My dog likes to run.", "example_cn": "我的狗喜欢跑。"},
|
||||
{"lemma_en": "bird", "zh": "鸟", "phonetic": "/bɜːrd/", "example_en": "A bird is singing.", "example_cn": "一只鸟在歌唱。"},
|
||||
{"lemma_en": "fish", "zh": "鱼", "phonetic": "/fɪʃ/", "example_en": "We saw a fish in the pond.", "example_cn": "我们在池塘里看到一条鱼。"},
|
||||
{"lemma_en": "water", "zh": "水", "phonetic": "/ˈwɔːtər/", "example_en": "Drink more water.", "example_cn": "多喝水。"},
|
||||
{"lemma_en": "milk", "zh": "牛奶", "phonetic": "/mɪlk/", "example_en": "She drinks milk every morning.", "example_cn": "她每天早上喝牛奶。"},
|
||||
{"lemma_en": "bread", "zh": "面包", "phonetic": "/bred/", "example_en": "Fresh bread smells good.", "example_cn": "新鲜面包闻起来很香。"},
|
||||
{"lemma_en": "rice", "zh": "米饭", "phonetic": "/raɪs/", "example_en": "Rice is a staple food.", "example_cn": "米饭是主食。"},
|
||||
{"lemma_en": "egg", "zh": "鸡蛋", "phonetic": "/eɡ/", "example_en": "I had an egg for breakfast.", "example_cn": "我早餐吃了一个鸡蛋。"},
|
||||
{"lemma_en": "mother", "zh": "妈妈", "phonetic": "/ˈmʌðər/", "example_en": "My mother cooks well.", "example_cn": "我妈妈做饭很好。"},
|
||||
{"lemma_en": "father", "zh": "爸爸", "phonetic": "/ˈfɑːðər/", "example_en": "My father works hard.", "example_cn": "我爸爸工作很努力。"},
|
||||
{"lemma_en": "friend", "zh": "朋友", "phonetic": "/frend/", "example_en": "A good friend helps you.", "example_cn": "好朋友会帮助你。"},
|
||||
{"lemma_en": "happy", "zh": "快乐的", "phonetic": "/ˈhæpi/", "example_en": "I feel happy today.", "example_cn": "我今天感到快乐。"},
|
||||
{"lemma_en": "sad", "zh": "悲伤的", "phonetic": "/sæd/", "example_en": "Don't be sad.", "example_cn": "不要悲伤。"},
|
||||
{"lemma_en": "big", "zh": "大的", "phonetic": "/bɪɡ/", "example_en": "That is a big house.", "example_cn": "那是一座大房子。"},
|
||||
{"lemma_en": "small", "zh": "小的", "phonetic": "/smɔːl/", "example_en": "The box is small.", "example_cn": "这个盒子很小。"},
|
||||
{"lemma_en": "red", "zh": "红色", "phonetic": "/red/", "example_en": "She wore a red dress.", "example_cn": "她穿了一条红裙子。"},
|
||||
{"lemma_en": "blue", "zh": "蓝色", "phonetic": "/bluː/", "example_en": "The sky is blue.", "example_cn": "天空是蓝色的。"},
|
||||
{"lemma_en": "green", "zh": "绿色", "phonetic": "/ɡriːn/", "example_en": "Grass is green.", "example_cn": "草是绿色的。"},
|
||||
{"lemma_en": "run", "zh": "跑", "phonetic": "/rʌn/", "example_en": "They run every morning.", "example_cn": "他们每天早上跑步。"},
|
||||
{"lemma_en": "walk", "zh": "走", "phonetic": "/wɔːk/", "example_en": "Let's walk to the park.", "example_cn": "我们走到公园去吧。"},
|
||||
{"lemma_en": "read", "zh": "读", "phonetic": "/riːd/", "example_en": "I like to read stories.", "example_cn": "我喜欢读故事。"},
|
||||
{"lemma_en": "write", "zh": "写", "phonetic": "/raɪt/", "example_en": "Please write your name.", "example_cn": "请写下你的名字。"},
|
||||
{"lemma_en": "computer", "zh": "电脑", "phonetic": "/kəmˈpjuːtər/", "example_en": "I use a computer for work.", "example_cn": "我用电脑工作。"},
|
||||
{"lemma_en": "phone", "zh": "手机", "phonetic": "/foʊn/", "example_en": "Her phone rang.", "example_cn": "她的手机响了。"},
|
||||
{"lemma_en": "music", "zh": "音乐", "phonetic": "/ˈmjuːzɪk/", "example_en": "I listen to music.", "example_cn": "我听音乐。"},
|
||||
{"lemma_en": "love", "zh": "爱", "phonetic": "/lʌv/", "example_en": "I love my family.", "example_cn": "我爱我的家人。"}
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
from config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, quiz_router, settings_router, translate_router, word_router
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="WordLoop API", version="1.0.0")
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router.router)
|
||||
app.include_router(translate_router.router)
|
||||
app.include_router(word_router.router)
|
||||
app.include_router(quiz_router.router)
|
||||
app.include_router(settings_router.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "WordLoop API", "docs": "/docs"}
|
||||
@@ -0,0 +1,71 @@
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, Text
|
||||
|
||||
from database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(String(50), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
created_at = Column(String(32), nullable=False)
|
||||
|
||||
|
||||
class Word(Base):
|
||||
__tablename__ = "words"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
source_text = Column(String(500), nullable=False)
|
||||
target_text = Column(String(500), nullable=False)
|
||||
source_lang = Column(String(8), nullable=False)
|
||||
target_lang = Column(String(8), nullable=False)
|
||||
phonetic = Column(String(128), nullable=True)
|
||||
example_en = Column(Text, nullable=True)
|
||||
example_cn = Column(Text, nullable=True)
|
||||
status = Column(String(32), nullable=False, default="new")
|
||||
correct_count = Column(Integer, nullable=False, default=0)
|
||||
wrong_count = Column(Integer, nullable=False, default=0)
|
||||
consecutive_correct_count = Column(Integer, nullable=False, default=0)
|
||||
mastery_score = Column(Integer, nullable=False, default=0)
|
||||
review_due_date = Column(String(32), nullable=True)
|
||||
last_reviewed_at = Column(String(32), nullable=True)
|
||||
created_at = Column(String(32), nullable=False)
|
||||
|
||||
|
||||
class QuizRecord(Base):
|
||||
__tablename__ = "quiz_records"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
word_id = Column(Integer, ForeignKey("words.id"), nullable=False)
|
||||
question_type = Column(String(32), nullable=False)
|
||||
user_answer = Column(String(500), nullable=False)
|
||||
correct_answer = Column(String(500), nullable=False)
|
||||
is_correct = Column(Integer, nullable=False)
|
||||
created_at = Column(String(32), nullable=False)
|
||||
|
||||
|
||||
class UserSettings(Base):
|
||||
__tablename__ = "user_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||
daily_target = Column(Integer, nullable=False, default=20)
|
||||
master_required_count = Column(Integer, nullable=False, default=3)
|
||||
weak_wrong_threshold = Column(Integer, nullable=False, default=3)
|
||||
|
||||
|
||||
class DictionaryEntry(Base):
|
||||
"""全局离线翻译词典,与用户个人 words 表分离。"""
|
||||
|
||||
__tablename__ = "dictionary_entries"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
lemma_en = Column(String(128), unique=True, nullable=False, index=True)
|
||||
zh = Column(String(256), nullable=False, index=True)
|
||||
phonetic = Column(String(128), nullable=True)
|
||||
example_en = Column(Text, nullable=True)
|
||||
example_cn = Column(Text, nullable=True)
|
||||
source = Column(String(64), nullable=True)
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
sqlalchemy==2.0.36
|
||||
bcrypt==4.2.1
|
||||
python-jose[cryptography]==3.3.0
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.6.1
|
||||
python-multipart==0.0.17
|
||||
pymysql==1.1.1
|
||||
@@ -0,0 +1,55 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import create_access_token, get_current_user, hash_password, verify_password
|
||||
from database import get_db
|
||||
from models import User, UserSettings
|
||||
from schemas import TokenResponse, UserLogin, UserOut, UserRegister
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
def register(data: UserRegister, db: Session = Depends(get_db)):
|
||||
if db.query(User).filter(User.username == data.username).first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
password_hash=hash_password(data.password),
|
||||
created_at=utc_now_iso(),
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
|
||||
settings = UserSettings(
|
||||
user_id=user.id,
|
||||
daily_target=20,
|
||||
master_required_count=3,
|
||||
weak_wrong_threshold=3,
|
||||
)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == data.username).first()
|
||||
if not user or not verify_password(data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token = create_access_token(user.id, user.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)):
|
||||
return current_user
|
||||
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import (
|
||||
DailyQuizResponse,
|
||||
QuizAnswerRequest,
|
||||
QuizAnswerResponse,
|
||||
QuizStatsResponse,
|
||||
)
|
||||
from services.quiz_service import quiz_service
|
||||
|
||||
router = APIRouter(prefix="/api/quiz", tags=["quiz"])
|
||||
|
||||
|
||||
@router.get("/daily", response_model=DailyQuizResponse)
|
||||
def daily_quiz(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = quiz_service.get_daily_quiz(db, current_user)
|
||||
return DailyQuizResponse(**result)
|
||||
|
||||
|
||||
@router.post("/answer", response_model=QuizAnswerResponse)
|
||||
def submit_answer(
|
||||
data: QuizAnswerRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = quiz_service.submit_answer(
|
||||
db,
|
||||
current_user,
|
||||
data.word_id,
|
||||
data.question_type,
|
||||
data.user_answer,
|
||||
data.correct_answer,
|
||||
)
|
||||
return QuizAnswerResponse(**result)
|
||||
|
||||
|
||||
@router.get("/stats", response_model=QuizStatsResponse)
|
||||
def quiz_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return quiz_service.get_stats(db, current_user)
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User, UserSettings
|
||||
from schemas import SettingsOut, SettingsUpdate
|
||||
from services.quiz_service import quiz_service
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("", response_model=SettingsOut)
|
||||
def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = quiz_service.get_settings(db, current_user)
|
||||
return SettingsOut(
|
||||
daily_target=s.daily_target,
|
||||
master_required_count=s.master_required_count,
|
||||
weak_wrong_threshold=s.weak_wrong_threshold,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("", response_model=SettingsOut)
|
||||
def update_settings(
|
||||
data: SettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
s = quiz_service.get_settings(db, current_user)
|
||||
if data.daily_target is not None:
|
||||
s.daily_target = data.daily_target
|
||||
if data.master_required_count is not None:
|
||||
s.master_required_count = data.master_required_count
|
||||
if data.weak_wrong_threshold is not None:
|
||||
s.weak_wrong_threshold = data.weak_wrong_threshold
|
||||
db.commit()
|
||||
db.refresh(s)
|
||||
return SettingsOut(
|
||||
daily_target=s.daily_target,
|
||||
master_required_count=s.master_required_count,
|
||||
weak_wrong_threshold=s.weak_wrong_threshold,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import TranslateRequest, TranslateResponse
|
||||
from services.translation_service import translation_service
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["translate"])
|
||||
|
||||
|
||||
@router.post("/translate", response_model=TranslateResponse)
|
||||
def translate(
|
||||
data: TranslateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
result = translation_service.translate(db, data.text)
|
||||
return TranslateResponse(**result)
|
||||
@@ -0,0 +1,60 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
from models import User
|
||||
from schemas import WordCreate, WordOut, WordUpdate
|
||||
from services.word_service import word_service
|
||||
|
||||
router = APIRouter(prefix="/api/words", tags=["words"])
|
||||
|
||||
|
||||
@router.post("", response_model=WordOut)
|
||||
def create_word(
|
||||
data: WordCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
word = word_service.create_word(db, current_user, data.model_dump())
|
||||
return word
|
||||
|
||||
|
||||
@router.get("", response_model=list[WordOut])
|
||||
def list_words(
|
||||
status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return word_service.list_words(db, current_user, status)
|
||||
|
||||
|
||||
@router.get("/{word_id}", response_model=WordOut)
|
||||
def get_word(
|
||||
word_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return word_service.get_word(db, current_user, word_id)
|
||||
|
||||
|
||||
@router.delete("/{word_id}")
|
||||
def delete_word(
|
||||
word_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
word_service.delete_word(db, current_user, word_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.patch("/{word_id}", response_model=WordOut)
|
||||
def update_word(
|
||||
word_id: int,
|
||||
data: WordUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
return word_service.update_word(db, current_user, word_id, data.model_dump(exclude_unset=True))
|
||||
@@ -0,0 +1,142 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# Auth
|
||||
class UserRegister(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=50)
|
||||
password: str = Field(min_length=6, max_length=100)
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Translate
|
||||
class TranslateRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TranslateResponse(BaseModel):
|
||||
source_text: str
|
||||
target_text: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
phonetic: Optional[str] = None
|
||||
example_en: Optional[str] = None
|
||||
example_cn: Optional[str] = None
|
||||
|
||||
|
||||
# Words
|
||||
class WordCreate(BaseModel):
|
||||
source_text: str
|
||||
target_text: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
phonetic: Optional[str] = None
|
||||
example_en: Optional[str] = None
|
||||
example_cn: Optional[str] = None
|
||||
|
||||
|
||||
class WordUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class WordOut(BaseModel):
|
||||
id: int
|
||||
user_id: int
|
||||
source_text: str
|
||||
target_text: str
|
||||
source_lang: str
|
||||
target_lang: str
|
||||
phonetic: Optional[str] = None
|
||||
example_en: Optional[str] = None
|
||||
example_cn: Optional[str] = None
|
||||
status: str
|
||||
correct_count: int
|
||||
wrong_count: int
|
||||
consecutive_correct_count: int
|
||||
mastery_score: int
|
||||
review_due_date: Optional[str] = None
|
||||
last_reviewed_at: Optional[str] = None
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Quiz
|
||||
class QuizOption(BaseModel):
|
||||
label: str
|
||||
text: str
|
||||
|
||||
|
||||
class QuizQuestion(BaseModel):
|
||||
word_id: int
|
||||
question_type: str
|
||||
prompt: str
|
||||
options: list[QuizOption]
|
||||
correct_answer: str
|
||||
|
||||
|
||||
class DailyQuizResponse(BaseModel):
|
||||
questions: list[QuizQuestion]
|
||||
total: int
|
||||
|
||||
|
||||
class QuizAnswerRequest(BaseModel):
|
||||
word_id: int
|
||||
question_type: str
|
||||
user_answer: str
|
||||
correct_answer: str
|
||||
|
||||
|
||||
class QuizAnswerResponse(BaseModel):
|
||||
is_correct: bool
|
||||
correct_answer: str
|
||||
word: WordOut
|
||||
|
||||
|
||||
class QuizStatsResponse(BaseModel):
|
||||
total_words: int
|
||||
new_count: int
|
||||
learning_count: int
|
||||
mastered_count: int
|
||||
weak_count: int
|
||||
today_quiz_count: int
|
||||
today_correct_count: int
|
||||
today_accuracy: float
|
||||
daily_target: int
|
||||
today_completed: int
|
||||
streak_days: int
|
||||
|
||||
|
||||
# Settings
|
||||
class SettingsOut(BaseModel):
|
||||
daily_target: int
|
||||
master_required_count: int
|
||||
weak_wrong_threshold: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
daily_target: Optional[int] = Field(None, ge=1, le=100)
|
||||
master_required_count: Optional[int] = Field(None, ge=1, le=20)
|
||||
weak_wrong_threshold: Optional[int] = Field(None, ge=1, le=20)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""词典导入共享工具。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import DictionaryEntry
|
||||
|
||||
_POS_PREFIX = re.compile(r"^[a-zA-Z]+\.\s*")
|
||||
_TAG_PREFIX = re.compile(r"^\[[^\]]+\]\s*")
|
||||
|
||||
|
||||
def clean_translation(text: str, max_len: int = 256) -> str:
|
||||
if not text:
|
||||
return ""
|
||||
lines = [ln.strip() for ln in text.replace("\r", "").split("\n") if ln.strip()]
|
||||
parts: list[str] = []
|
||||
for ln in lines:
|
||||
ln = _TAG_PREFIX.sub("", ln)
|
||||
ln = _POS_PREFIX.sub("", ln)
|
||||
if ln:
|
||||
parts.append(ln)
|
||||
result = ";".join(parts) if parts else text.strip()
|
||||
return result[:max_len]
|
||||
|
||||
|
||||
def normalize_row(raw: dict, source: Optional[str]) -> Optional[dict]:
|
||||
lemma_en = (raw.get("lemma_en") or raw.get("en") or raw.get("word") or "").strip().lower()
|
||||
zh = clean_translation(raw.get("zh") or raw.get("cn") or raw.get("translation") or "")
|
||||
if not lemma_en or not zh:
|
||||
return None
|
||||
if len(lemma_en) > 128:
|
||||
return None
|
||||
phonetic = (raw.get("phonetic") or "").strip() or None
|
||||
if phonetic and len(phonetic) > 128:
|
||||
phonetic = phonetic[:128]
|
||||
return {
|
||||
"lemma_en": lemma_en,
|
||||
"zh": zh,
|
||||
"phonetic": phonetic,
|
||||
"example_en": (raw.get("example_en") or "").strip() or None,
|
||||
"example_cn": (raw.get("example_cn") or "").strip() or None,
|
||||
"source": source or (raw.get("source") or "import"),
|
||||
}
|
||||
|
||||
|
||||
def upsert_batch(session: Session, rows: list[dict]) -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
stmt = mysql_insert(DictionaryEntry).values(rows)
|
||||
stmt = stmt.on_duplicate_key_update(
|
||||
zh=stmt.inserted.zh,
|
||||
phonetic=stmt.inserted.phonetic,
|
||||
example_en=stmt.inserted.example_en,
|
||||
example_cn=stmt.inserted.example_cn,
|
||||
source=stmt.inserted.source,
|
||||
)
|
||||
session.execute(stmt)
|
||||
return len(rows)
|
||||
|
||||
|
||||
def upsert_entries(session: Session, raw_rows: list[dict], source: Optional[str], batch_size: int = 2000) -> int:
|
||||
batch: list[dict] = []
|
||||
total = 0
|
||||
for raw in raw_rows:
|
||||
row = normalize_row(raw, source)
|
||||
if not row:
|
||||
continue
|
||||
batch.append(row)
|
||||
if len(batch) >= batch_size:
|
||||
upsert_batch(session, batch)
|
||||
session.commit()
|
||||
total += len(batch)
|
||||
batch.clear()
|
||||
if batch:
|
||||
upsert_batch(session, batch)
|
||||
session.commit()
|
||||
total += len(batch)
|
||||
return total
|
||||
@@ -0,0 +1,83 @@
|
||||
"""离线翻译词典导入脚本。
|
||||
|
||||
用法(在 backend 目录下):
|
||||
python -m scripts.import_dictionary
|
||||
python -m scripts.import_dictionary --file data/dictionary.json
|
||||
python -m scripts.import_dictionary --file /path/to/words.csv
|
||||
python -m scripts.import_dictionary --file data/dictionary.json --source seed
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_FILE = BACKEND_DIR / "data" / "dictionary.json"
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from database import SessionLocal # noqa: E402
|
||||
from scripts.dictionary_importer import upsert_entries # noqa: E402
|
||||
|
||||
|
||||
def load_json(path: Path) -> list[dict]:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict) and "entries" in data:
|
||||
return data["entries"]
|
||||
raise ValueError(f"无法解析 JSON 格式: {path}")
|
||||
|
||||
|
||||
def load_csv(path: Path) -> list[dict]:
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
return list(reader)
|
||||
|
||||
|
||||
def load_entries(path: Path) -> list[dict]:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".json":
|
||||
return load_json(path)
|
||||
if suffix == ".csv":
|
||||
return load_csv(path)
|
||||
raise ValueError(f"不支持的文件格式: {suffix},请使用 .json 或 .csv")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="导入离线翻译词典到 dictionary_entries 表")
|
||||
parser.add_argument(
|
||||
"--file",
|
||||
type=Path,
|
||||
default=DEFAULT_FILE,
|
||||
help=f"JSON 或 CSV 文件路径(默认: {DEFAULT_FILE.name})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=str,
|
||||
default=None,
|
||||
help="数据来源标记,如 seed / ecdict / custom",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
path = args.file if args.file.is_absolute() else BACKEND_DIR / args.file
|
||||
if not path.exists():
|
||||
print(f"错误: 文件不存在 {path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
raw_rows = load_entries(path)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
total = upsert_entries(session, raw_rows, args.source)
|
||||
finally:
|
||||
session.close()
|
||||
print(f"导入完成: 文件={path.name}, 处理={total} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""从线上 ECDICT 词库下载并导入 MySQL。
|
||||
|
||||
数据源: https://github.com/skywind3000/ECDICT (开源英汉词典,约 340 万词条)
|
||||
|
||||
用法(在 backend 目录下):
|
||||
python -m scripts.import_dictionary_online
|
||||
python -m scripts.import_dictionary_online --preset full
|
||||
python -m scripts.import_dictionary_online --preset standard --skip-download
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sqlite3
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parent.parent
|
||||
CACHE_DIR = BACKEND_DIR / "data" / "cache"
|
||||
ECDICT_ZIP_URL = (
|
||||
"https://github.com/skywind3000/ECDICT/releases/download/1.0.28/ecdict-sqlite-28.zip"
|
||||
)
|
||||
ECDICT_ZIP_PATH = CACHE_DIR / "ecdict-sqlite-28.zip"
|
||||
ECDICT_DB_PATH = CACHE_DIR / "stardict.db"
|
||||
|
||||
sys.path.insert(0, str(BACKEND_DIR))
|
||||
|
||||
from database import SessionLocal # noqa: E402
|
||||
from scripts.dictionary_importer import upsert_entries # noqa: E402
|
||||
|
||||
PRESET_SQL = {
|
||||
# 常用词:Collins / 牛津核心词,或词频排名前 5 万
|
||||
"standard": """
|
||||
translation IS NOT NULL AND translation != ''
|
||||
AND (
|
||||
collins >= 1 OR oxford = 1
|
||||
OR (frq IS NOT NULL AND frq <= 50000)
|
||||
)
|
||||
""",
|
||||
# 核心词:Collins 星级或牛津 3000
|
||||
"core": """
|
||||
translation IS NOT NULL AND translation != ''
|
||||
AND (collins >= 1 OR oxford = 1)
|
||||
""",
|
||||
# 全量(约 338 万,耗时较长)
|
||||
"full": """
|
||||
translation IS NOT NULL AND translation != ''
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
def download(url: str, dest: Path) -> None:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"正在下载 ECDICT SQLite(约 207MB)…")
|
||||
print(f" {url}")
|
||||
|
||||
def progress(block_num: int, block_size: int, total_size: int) -> None:
|
||||
if total_size <= 0:
|
||||
return
|
||||
done = block_num * block_size
|
||||
pct = min(100, done * 100 // total_size)
|
||||
mb = done / 1024 / 1024
|
||||
total_mb = total_size / 1024 / 1024
|
||||
print(f"\r 进度: {pct:3d}% ({mb:.1f}/{total_mb:.1f} MB)", end="", flush=True)
|
||||
|
||||
urlretrieve(url, dest, reporthook=progress)
|
||||
print()
|
||||
|
||||
|
||||
def ensure_ecdict_db(skip_download: bool) -> Path:
|
||||
if ECDICT_DB_PATH.exists():
|
||||
return ECDICT_DB_PATH
|
||||
|
||||
if not ECDICT_ZIP_PATH.exists():
|
||||
if skip_download:
|
||||
print(f"错误: 未找到本地词库,请先下载或去掉 --skip-download", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
download(ECDICT_ZIP_URL, ECDICT_ZIP_PATH)
|
||||
|
||||
print(f"正在解压 {ECDICT_ZIP_PATH.name} …")
|
||||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(ECDICT_ZIP_PATH, "r") as zf:
|
||||
zf.extract("stardict.db", CACHE_DIR)
|
||||
print(f"词库就绪: {ECDICT_DB_PATH}")
|
||||
return ECDICT_DB_PATH
|
||||
|
||||
|
||||
def count_entries(db_path: Path, where: str) -> int:
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cur = conn.execute(f"SELECT COUNT(*) FROM stardict WHERE {where}")
|
||||
return int(cur.fetchone()[0])
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def iter_ecdict_rows(db_path: Path, where: str, limit: Optional[int]):
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
sql = f"""
|
||||
SELECT word, phonetic, translation
|
||||
FROM stardict
|
||||
WHERE {where}
|
||||
ORDER BY COALESCE(frq, 9999999), word
|
||||
"""
|
||||
if limit:
|
||||
sql += f" LIMIT {int(limit)}"
|
||||
cur = conn.execute(sql)
|
||||
while True:
|
||||
rows = cur.fetchmany(5000)
|
||||
if not rows:
|
||||
break
|
||||
for row in rows:
|
||||
yield {
|
||||
"word": row["word"],
|
||||
"phonetic": row["phonetic"],
|
||||
"translation": row["translation"],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def import_from_ecdict(
|
||||
db_path: Path,
|
||||
preset: str,
|
||||
limit: Optional[int],
|
||||
batch_size: int,
|
||||
) -> int:
|
||||
where = PRESET_SQL[preset]
|
||||
total_expected = count_entries(db_path, where)
|
||||
if limit:
|
||||
total_expected = min(total_expected, limit)
|
||||
print(f"预设: {preset},预计导入 {total_expected:,} 条")
|
||||
|
||||
session = SessionLocal()
|
||||
imported = 0
|
||||
buffer: list[dict] = []
|
||||
try:
|
||||
for row in iter_ecdict_rows(db_path, where, limit):
|
||||
buffer.append(row)
|
||||
if len(buffer) >= batch_size:
|
||||
imported += upsert_entries(session, buffer, source="ecdict", batch_size=batch_size)
|
||||
buffer.clear()
|
||||
print(f"\r 已导入 {imported:,} / {total_expected:,}", end="", flush=True)
|
||||
if buffer:
|
||||
imported += upsert_entries(session, buffer, source="ecdict", batch_size=batch_size)
|
||||
print()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
return imported
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="从线上 ECDICT 下载并导入离线翻译词典")
|
||||
parser.add_argument(
|
||||
"--preset",
|
||||
choices=list(PRESET_SQL.keys()),
|
||||
default="standard",
|
||||
help="standard=常用约 83 万 | core=核心约 1.4 万 | full=全量约 338 万(默认 standard)",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=None, help="最多导入条数(调试用)")
|
||||
parser.add_argument("--batch-size", type=int, default=2000, help="MySQL 批量写入大小")
|
||||
parser.add_argument("--skip-download", action="store_true", help="跳过下载,使用本地 cache")
|
||||
args = parser.parse_args()
|
||||
|
||||
db_path = ensure_ecdict_db(args.skip_download)
|
||||
imported = import_from_ecdict(db_path, args.preset, args.limit, args.batch_size)
|
||||
print(f"导入完成: {imported:,} 条(source=ecdict, preset={args.preset})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 使用有建库权限的账号执行,例如:mysql -u boot -p < scripts/init_mysql.sql
|
||||
CREATE DATABASE IF NOT EXISTS wordloop
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,55 @@
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import DictionaryEntry
|
||||
|
||||
|
||||
class DictionaryService:
|
||||
def lookup_en(self, db: Session, text: str) -> Optional[DictionaryEntry]:
|
||||
key = text.lower().strip()
|
||||
if not key:
|
||||
return None
|
||||
return db.query(DictionaryEntry).filter(DictionaryEntry.lemma_en == key).first()
|
||||
|
||||
def lookup_zh(self, db: Session, text: str) -> Optional[DictionaryEntry]:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
return None
|
||||
entry = db.query(DictionaryEntry).filter(DictionaryEntry.zh == text).first()
|
||||
if entry:
|
||||
return entry
|
||||
return (
|
||||
db.query(DictionaryEntry)
|
||||
.filter(DictionaryEntry.zh.contains(text))
|
||||
.first()
|
||||
)
|
||||
|
||||
def random_zh_values(self, db: Session, exclude: str, limit: int) -> list[str]:
|
||||
rows = (
|
||||
db.query(DictionaryEntry.zh)
|
||||
.filter(DictionaryEntry.zh != exclude)
|
||||
.order_by(DictionaryEntry.id)
|
||||
.all()
|
||||
)
|
||||
values = [r[0] for r in rows]
|
||||
random.shuffle(values)
|
||||
return values[:limit]
|
||||
|
||||
def random_en_lemmas(self, db: Session, exclude: str, limit: int) -> list[str]:
|
||||
rows = (
|
||||
db.query(DictionaryEntry.lemma_en)
|
||||
.filter(DictionaryEntry.lemma_en != exclude.lower())
|
||||
.order_by(DictionaryEntry.id)
|
||||
.all()
|
||||
)
|
||||
values = [r[0] for r in rows]
|
||||
random.shuffle(values)
|
||||
return values[:limit]
|
||||
|
||||
def count(self, db: Session) -> int:
|
||||
return db.query(DictionaryEntry).count()
|
||||
|
||||
|
||||
dictionary_service = DictionaryService()
|
||||
@@ -0,0 +1,269 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import QuizRecord, User, UserSettings, Word
|
||||
from schemas import QuizOption, QuizQuestion, QuizStatsResponse, WordOut
|
||||
from services.dictionary_service import dictionary_service
|
||||
from services.word_service import calc_mastery_score, utc_now_iso
|
||||
|
||||
|
||||
def today_str() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def review_interval_days(consecutive: int) -> int:
|
||||
"""根据连续答对次数计算下次复习间隔(天)。"""
|
||||
if consecutive <= 0:
|
||||
return 0
|
||||
if consecutive == 1:
|
||||
return 1
|
||||
if consecutive == 2:
|
||||
return 3
|
||||
if consecutive == 3:
|
||||
return 7
|
||||
if consecutive >= 5:
|
||||
return 15
|
||||
return 7
|
||||
|
||||
|
||||
class QuizService:
|
||||
def get_settings(self, db: Session, user: User) -> UserSettings:
|
||||
settings = db.query(UserSettings).filter(UserSettings.user_id == user.id).first()
|
||||
if not settings:
|
||||
settings = UserSettings(user_id=user.id)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
def select_daily_words(self, db: Session, user: User, limit: int) -> list[Word]:
|
||||
today = today_str()
|
||||
all_words = db.query(Word).filter(Word.user_id == user.id).all()
|
||||
|
||||
weak = [w for w in all_words if w.status == "weak"]
|
||||
learning = [w for w in all_words if w.status == "learning"]
|
||||
new = [w for w in all_words if w.status == "new"]
|
||||
mastered_due = [
|
||||
w
|
||||
for w in all_words
|
||||
if w.status == "mastered"
|
||||
and w.review_due_date
|
||||
and w.review_due_date <= today
|
||||
]
|
||||
|
||||
pool: list[Word] = []
|
||||
for group in (weak, learning, new, mastered_due):
|
||||
random.shuffle(group)
|
||||
for w in group:
|
||||
if w not in pool:
|
||||
pool.append(w)
|
||||
if len(pool) >= limit:
|
||||
return pool[:limit]
|
||||
return pool[:limit]
|
||||
|
||||
def build_question(self, db: Session, word: Word, all_words: list[Word]) -> QuizQuestion:
|
||||
question_type = random.choice(["en_to_zh", "zh_to_en"])
|
||||
|
||||
if question_type == "en_to_zh":
|
||||
en = word.target_text if word.source_lang == "zh" else word.source_text
|
||||
zh = word.source_text if word.source_lang == "zh" else word.target_text
|
||||
prompt = en
|
||||
correct = zh
|
||||
distractor_pool = [
|
||||
(w.source_text if w.source_lang == "zh" else w.target_text)
|
||||
for w in all_words
|
||||
if w.id != word.id
|
||||
]
|
||||
else:
|
||||
zh = word.source_text if word.source_lang == "zh" else word.target_text
|
||||
en = word.target_text if word.source_lang == "zh" else word.source_text
|
||||
prompt = zh
|
||||
correct = en
|
||||
distractor_pool = [
|
||||
(w.target_text if w.source_lang == "zh" else w.source_text)
|
||||
for w in all_words
|
||||
if w.id != word.id
|
||||
]
|
||||
|
||||
distractors = list({d for d in distractor_pool if d != correct})
|
||||
random.shuffle(distractors)
|
||||
if len(distractors) < 3:
|
||||
if question_type == "en_to_zh":
|
||||
extra = dictionary_service.random_zh_values(db, correct, 10)
|
||||
else:
|
||||
extra = dictionary_service.random_en_lemmas(db, correct, 10)
|
||||
for e in extra:
|
||||
if e not in distractors:
|
||||
distractors.append(e)
|
||||
if len(distractors) >= 3:
|
||||
break
|
||||
options_text = [correct] + distractors[:3]
|
||||
random.shuffle(options_text)
|
||||
|
||||
labels = ["A", "B", "C", "D"]
|
||||
options = [
|
||||
QuizOption(label=labels[i], text=options_text[i])
|
||||
for i in range(min(4, len(options_text)))
|
||||
]
|
||||
|
||||
return QuizQuestion(
|
||||
word_id=word.id,
|
||||
question_type=question_type,
|
||||
prompt=prompt,
|
||||
options=options,
|
||||
correct_answer=correct,
|
||||
)
|
||||
|
||||
def get_daily_quiz(self, db: Session, user: User) -> dict:
|
||||
settings = self.get_settings(db, user)
|
||||
words = self.select_daily_words(db, user, settings.daily_target)
|
||||
all_words = db.query(Word).filter(Word.user_id == user.id).all()
|
||||
|
||||
if len(all_words) < 4:
|
||||
questions = []
|
||||
if words:
|
||||
questions = [self.build_question(db, words[0], all_words)]
|
||||
else:
|
||||
questions = [self.build_question(db, w, all_words) for w in words]
|
||||
|
||||
return {
|
||||
"questions": questions,
|
||||
"total": len(questions),
|
||||
}
|
||||
|
||||
def submit_answer(
|
||||
self,
|
||||
db: Session,
|
||||
user: User,
|
||||
word_id: int,
|
||||
question_type: str,
|
||||
user_answer: str,
|
||||
correct_answer: str,
|
||||
) -> dict:
|
||||
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
|
||||
if not word:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="单词不存在")
|
||||
|
||||
settings = self.get_settings(db, user)
|
||||
is_correct = user_answer.strip() == correct_answer.strip()
|
||||
now = utc_now_iso()
|
||||
today = today_str()
|
||||
|
||||
if is_correct:
|
||||
word.correct_count += 1
|
||||
word.consecutive_correct_count += 1
|
||||
if word.status == "new":
|
||||
word.status = "learning"
|
||||
if word.consecutive_correct_count >= settings.master_required_count:
|
||||
word.status = "mastered"
|
||||
days = review_interval_days(word.consecutive_correct_count)
|
||||
due = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
word.review_due_date = due.strftime("%Y-%m-%d")
|
||||
else:
|
||||
word.wrong_count += 1
|
||||
word.consecutive_correct_count = 0
|
||||
if word.wrong_count >= settings.weak_wrong_threshold:
|
||||
word.status = "weak"
|
||||
elif word.status == "new":
|
||||
word.status = "learning"
|
||||
word.review_due_date = today
|
||||
|
||||
word.mastery_score = calc_mastery_score(word.correct_count, word.wrong_count)
|
||||
word.last_reviewed_at = now
|
||||
|
||||
record = QuizRecord(
|
||||
user_id=user.id,
|
||||
word_id=word.id,
|
||||
question_type=question_type,
|
||||
user_answer=user_answer,
|
||||
correct_answer=correct_answer,
|
||||
is_correct=1 if is_correct else 0,
|
||||
created_at=now,
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(word)
|
||||
|
||||
return {
|
||||
"is_correct": is_correct,
|
||||
"correct_answer": correct_answer,
|
||||
"word": WordOut.model_validate(word),
|
||||
}
|
||||
|
||||
def get_stats(self, db: Session, user: User) -> QuizStatsResponse:
|
||||
settings = self.get_settings(db, user)
|
||||
today = today_str()
|
||||
|
||||
total = db.query(Word).filter(Word.user_id == user.id).count()
|
||||
new_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "new").count()
|
||||
learning_count = (
|
||||
db.query(Word).filter(Word.user_id == user.id, Word.status == "learning").count()
|
||||
)
|
||||
mastered_count = (
|
||||
db.query(Word).filter(Word.user_id == user.id, Word.status == "mastered").count()
|
||||
)
|
||||
weak_count = db.query(Word).filter(Word.user_id == user.id, Word.status == "weak").count()
|
||||
|
||||
today_records = (
|
||||
db.query(QuizRecord)
|
||||
.filter(
|
||||
QuizRecord.user_id == user.id,
|
||||
func.date(QuizRecord.created_at) == today,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
# SQLite date compare fallback: filter by prefix
|
||||
if not today_records:
|
||||
today_records = [
|
||||
r
|
||||
for r in db.query(QuizRecord).filter(QuizRecord.user_id == user.id).all()
|
||||
if r.created_at.startswith(today)
|
||||
]
|
||||
|
||||
today_quiz_count = len(today_records)
|
||||
today_correct = sum(1 for r in today_records if r.is_correct == 1)
|
||||
accuracy = (
|
||||
round(today_correct / today_quiz_count * 100, 1) if today_quiz_count > 0 else 0.0
|
||||
)
|
||||
|
||||
streak = self._calc_streak(db, user)
|
||||
|
||||
return QuizStatsResponse(
|
||||
total_words=total,
|
||||
new_count=new_count,
|
||||
learning_count=learning_count,
|
||||
mastered_count=mastered_count,
|
||||
weak_count=weak_count,
|
||||
today_quiz_count=today_quiz_count,
|
||||
today_correct_count=today_correct,
|
||||
today_accuracy=accuracy,
|
||||
daily_target=settings.daily_target,
|
||||
today_completed=today_quiz_count,
|
||||
streak_days=streak,
|
||||
)
|
||||
|
||||
def _calc_streak(self, db: Session, user: User) -> int:
|
||||
records = (
|
||||
db.query(QuizRecord)
|
||||
.filter(QuizRecord.user_id == user.id)
|
||||
.order_by(QuizRecord.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
days_with_quiz = set()
|
||||
for r in records:
|
||||
days_with_quiz.add(r.created_at[:10])
|
||||
|
||||
streak = 0
|
||||
d = datetime.now(timezone.utc).date()
|
||||
while d.isoformat() in days_with_quiz:
|
||||
streak += 1
|
||||
d -= timedelta(days=1)
|
||||
return streak
|
||||
|
||||
|
||||
quiz_service = QuizService()
|
||||
@@ -0,0 +1,85 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.dictionary_service import dictionary_service
|
||||
|
||||
|
||||
def contains_chinese(text: str) -> bool:
|
||||
return bool(re.search(r"[\u4e00-\u9fff]", text))
|
||||
|
||||
|
||||
class TranslationService:
|
||||
"""翻译服务:优先查离线词典表,未命中时返回占位结果(暂不接入外部 API)。"""
|
||||
|
||||
def translate(self, db: Session, text: str) -> dict:
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise ValueError("输入不能为空")
|
||||
|
||||
if contains_chinese(text):
|
||||
return self._zh_to_en(db, text)
|
||||
return self._en_to_zh(db, text)
|
||||
|
||||
def _en_to_zh(self, db: Session, text: str) -> dict:
|
||||
entry = dictionary_service.lookup_en(db, text)
|
||||
if entry:
|
||||
return self._build_response(
|
||||
text,
|
||||
entry.zh,
|
||||
"en",
|
||||
"zh",
|
||||
entry.phonetic,
|
||||
entry.example_en,
|
||||
entry.example_cn,
|
||||
)
|
||||
return self._placeholder(text, text, "en", "zh")
|
||||
|
||||
def _zh_to_en(self, db: Session, text: str) -> dict:
|
||||
entry = dictionary_service.lookup_zh(db, text)
|
||||
if entry:
|
||||
return self._build_response(
|
||||
text,
|
||||
entry.lemma_en,
|
||||
"zh",
|
||||
"en",
|
||||
entry.phonetic,
|
||||
entry.example_en,
|
||||
entry.example_cn,
|
||||
)
|
||||
return self._placeholder(text, f"[{text}]", "zh", "en")
|
||||
|
||||
def _build_response(
|
||||
self,
|
||||
source: str,
|
||||
target: str,
|
||||
source_lang: str,
|
||||
target_lang: str,
|
||||
phonetic: Optional[str],
|
||||
example_en: Optional[str],
|
||||
example_cn: Optional[str],
|
||||
) -> dict:
|
||||
return {
|
||||
"source_text": source,
|
||||
"target_text": target,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"phonetic": phonetic,
|
||||
"example_en": example_en,
|
||||
"example_cn": example_cn,
|
||||
}
|
||||
|
||||
def _placeholder(self, source: str, target: str, source_lang: str, target_lang: str) -> dict:
|
||||
return {
|
||||
"source_text": source,
|
||||
"target_text": target,
|
||||
"source_lang": source_lang,
|
||||
"target_lang": target_lang,
|
||||
"phonetic": None,
|
||||
"example_en": f"Example with {target}." if target_lang == "en" else None,
|
||||
"example_cn": f"包含「{source}」的例句。" if source_lang == "zh" else None,
|
||||
}
|
||||
|
||||
|
||||
translation_service = TranslationService()
|
||||
@@ -0,0 +1,83 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models import Word, User
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def calc_mastery_score(correct: int, wrong: int) -> int:
|
||||
total = correct + wrong
|
||||
if total == 0:
|
||||
return 0
|
||||
return round(correct / total * 100)
|
||||
|
||||
|
||||
class WordService:
|
||||
def create_word(self, db: Session, user: User, data: dict) -> Word:
|
||||
existing = (
|
||||
db.query(Word)
|
||||
.filter(
|
||||
Word.user_id == user.id,
|
||||
Word.source_text == data["source_text"],
|
||||
Word.target_text == data["target_text"],
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该单词已存在")
|
||||
|
||||
word = Word(
|
||||
user_id=user.id,
|
||||
source_text=data["source_text"],
|
||||
target_text=data["target_text"],
|
||||
source_lang=data["source_lang"],
|
||||
target_lang=data["target_lang"],
|
||||
phonetic=data.get("phonetic"),
|
||||
example_en=data.get("example_en"),
|
||||
example_cn=data.get("example_cn"),
|
||||
status="new",
|
||||
correct_count=0,
|
||||
wrong_count=0,
|
||||
consecutive_correct_count=0,
|
||||
mastery_score=0,
|
||||
review_due_date=datetime.now(timezone.utc).strftime("%Y-%m-%d"),
|
||||
created_at=utc_now_iso(),
|
||||
)
|
||||
db.add(word)
|
||||
db.commit()
|
||||
db.refresh(word)
|
||||
return word
|
||||
|
||||
def list_words(self, db: Session, user: User, status: Optional[str] = None) -> list[Word]:
|
||||
q = db.query(Word).filter(Word.user_id == user.id)
|
||||
if status:
|
||||
q = q.filter(Word.status == status)
|
||||
return q.order_by(Word.created_at.desc()).all()
|
||||
|
||||
def get_word(self, db: Session, user: User, word_id: int) -> Word:
|
||||
word = db.query(Word).filter(Word.id == word_id, Word.user_id == user.id).first()
|
||||
if not word:
|
||||
raise HTTPException(status_code=404, detail="单词不存在")
|
||||
return word
|
||||
|
||||
def delete_word(self, db: Session, user: User, word_id: int) -> None:
|
||||
word = self.get_word(db, user, word_id)
|
||||
db.delete(word)
|
||||
db.commit()
|
||||
|
||||
def update_word(self, db: Session, user: User, word_id: int, data: dict) -> Word:
|
||||
word = self.get_word(db, user, word_id)
|
||||
if "status" in data and data["status"]:
|
||||
word.status = data["status"]
|
||||
db.commit()
|
||||
db.refresh(word)
|
||||
return word
|
||||
|
||||
|
||||
word_service = WordService()
|
||||
Reference in New Issue
Block a user