Initial commit: WordLoop 单词学习应用

Vue 前端 + FastAPI 后端,含部署脚本与词典数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-04 14:30:53 -07:00
commit bd7635986a
66 changed files with 5495 additions and 0 deletions
+8
View File
@@ -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
+56
View File
@@ -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
+33
View File
@@ -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()
+38
View File
@@ -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": "我爱我的家人。"}
]
+23
View File
@@ -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()
+28
View File
@@ -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"}
+71
View File
@@ -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)
+9
View File
@@ -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
View File
+55
View File
@@ -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
+49
View File
@@ -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)
+45
View File
@@ -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,
)
+20
View File
@@ -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)
+60
View File
@@ -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))
+142
View File
@@ -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)
View File
+83
View File
@@ -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
+83
View File
@@ -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()
+179
View File
@@ -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()
+4
View File
@@ -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;
View File
+55
View File
@@ -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()
+269
View File
@@ -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()
+85
View File
@@ -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()
+83
View File
@@ -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()