From 6a01fc2ab538b37488797934eb54d43ea161ac1f Mon Sep 17 00:00:00 2001
From: Miyamizu-MitsuhaSang <2510681107@qq.com>
Date: Thu, 7 Aug 2025 23:55:37 +0800
Subject: [PATCH] Initial Commit
---
.gitignore | 321 ++++++++++++++++++
.idea/dataSources.xml | 12 +
.idea/dict_server.iml | 10 +
.../inspectionProfiles/profiles_settings.xml | 6 +
.idea/misc.xml | 7 +
.idea/modules.xml | 8 +
.idea/sqldialects.xml | 7 +
.idea/vcs.xml | 6 +
README.txt | 1 +
app/__init__.py | 0
app/api/__init__.py | 0
app/api/admin/__init__.py | 0
app/api/admin/dict.py | 200 +++++++++++
app/api/admin/router.py | 3 +
app/api/search.py | 22 ++
app/api/users.py | 108 ++++++
app/core/__init__.py | 0
app/core/redis.py | 18 +
app/models/__init__.py | 0
app/models/base.py | 41 +++
app/models/fr.py | 153 +++++++++
app/models/jp.py | 73 ++++
app/schemas/__init__.py | 0
app/schemas/admin_schemas.py | 104 ++++++
app/schemas/user_schemas.py | 59 ++++
app/schemas/word_schemas.py | 1 +
app/utils/__init__.py | 0
app/utils/security.py | 135 ++++++++
main.py | 31 ++
migrations/models/10_20250803090700_update.py | 11 +
migrations/models/11_20250803092810_update.py | 18 +
migrations/models/12_20250803094004_update.py | 19 ++
migrations/models/13_20250805203751_update.py | 21 ++
migrations/models/14_20250805204351_update.py | 11 +
migrations/models/15_20250805205550_update.py | 13 +
migrations/models/16_20250806104900_update.py | 11 +
migrations/models/9_20250802004957_None.py | 59 ++++
pyproject.toml | 4 +
requirements.txt | 122 +++++++
settings.py | 35 ++
40 files changed, 1650 insertions(+)
create mode 100644 .gitignore
create mode 100644 .idea/dataSources.xml
create mode 100644 .idea/dict_server.iml
create mode 100644 .idea/inspectionProfiles/profiles_settings.xml
create mode 100644 .idea/misc.xml
create mode 100644 .idea/modules.xml
create mode 100644 .idea/sqldialects.xml
create mode 100644 .idea/vcs.xml
create mode 100644 README.txt
create mode 100644 app/__init__.py
create mode 100644 app/api/__init__.py
create mode 100644 app/api/admin/__init__.py
create mode 100644 app/api/admin/dict.py
create mode 100644 app/api/admin/router.py
create mode 100644 app/api/search.py
create mode 100644 app/api/users.py
create mode 100644 app/core/__init__.py
create mode 100644 app/core/redis.py
create mode 100644 app/models/__init__.py
create mode 100644 app/models/base.py
create mode 100644 app/models/fr.py
create mode 100644 app/models/jp.py
create mode 100644 app/schemas/__init__.py
create mode 100644 app/schemas/admin_schemas.py
create mode 100644 app/schemas/user_schemas.py
create mode 100644 app/schemas/word_schemas.py
create mode 100644 app/utils/__init__.py
create mode 100644 app/utils/security.py
create mode 100644 main.py
create mode 100644 migrations/models/10_20250803090700_update.py
create mode 100644 migrations/models/11_20250803092810_update.py
create mode 100644 migrations/models/12_20250803094004_update.py
create mode 100644 migrations/models/13_20250805203751_update.py
create mode 100644 migrations/models/14_20250805204351_update.py
create mode 100644 migrations/models/15_20250805205550_update.py
create mode 100644 migrations/models/16_20250806104900_update.py
create mode 100644 migrations/models/9_20250802004957_None.py
create mode 100644 pyproject.toml
create mode 100644 requirements.txt
create mode 100644 settings.py
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0933448
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,321 @@
+# Created by https://www.toptal.com/developers/gitignore/api/python,pycharm,macos
+# Edit at https://www.toptal.com/developers/gitignore?templates=python,pycharm,macos
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### PyCharm ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### PyCharm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+# Azure Toolkit for IntelliJ plugin
+# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
+.idea/**/azureSettings.xml
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+### Python Patch ###
+# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
+poetry.toml
+
+# ruff
+.ruff_cache/
+
+# LSP config files
+pyrightconfig.json
+
+# End of https://www.toptal.com/developers/gitignore/api/python,pycharm,macos
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..224b18d
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ mysql.8
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://localhost:3306/dict
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/dict_server.iml b/.idea/dict_server.iml
new file mode 100644
index 0000000..5305fe2
--- /dev/null
+++ b/.idea/dict_server.iml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..dbda99f
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..922a8c6
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml
new file mode 100644
index 0000000..4f0d6ed
--- /dev/null
+++ b/.idea/sqldialects.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.txt b/README.txt
new file mode 100644
index 0000000..fd1e3e0
--- /dev/null
+++ b/README.txt
@@ -0,0 +1 @@
+./app/utils/security.py: get_current_user()新版本尚未启用
\ No newline at end of file
diff --git a/app/__init__.py b/app/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/__init__.py b/app/api/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/admin/__init__.py b/app/api/admin/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/admin/dict.py b/app/api/admin/dict.py
new file mode 100644
index 0000000..4555b78
--- /dev/null
+++ b/app/api/admin/dict.py
@@ -0,0 +1,200 @@
+from fastapi import Depends, HTTPException, Request, Query
+from typing import Literal, Tuple, Union
+
+from tortoise.exceptions import DoesNotExist
+
+from app.models.base import User
+from app.models.fr import DefinitionFr
+from app.utils.security import get_current_user
+from app.api.admin.router import admin_router
+import app.models.fr as fr
+import app.models.jp as jp
+from app.schemas.admin_schemas import CreateWord, UpdateWordSet, UpdateWord, SearchWordRequest
+
+
+@admin_router.get("/dict")
+async def get_wordlist(request: Request,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(10, le=10),
+ lang_code: Literal["fr", "jp"] = "fr",
+ admin_user: Tuple[User, dict] = Depends(get_current_user)):
+ """
+ 后台管理系统中关于词典部分的初始界面,分页显示
+ :param request: 请求头
+ :param page: 显示的表格视窗的页数,起始默认为 1
+ :param page_size: 控制每页的单词内容条数
+ :param lang_code: 查询并显示对应语言的单词表
+ :return: None
+ """
+ if not admin_user[0].is_admin:
+ raise HTTPException(status_code=403, detail="非管理员,无权限访问")
+ offset = (page - 1) * page_size
+ if lang_code == "fr":
+ total = await fr.DefinitionFr.all().count()
+ wordlist = await fr.DefinitionFr.all().offset(offset).limit(page_size).values(
+ "word__text",
+ "pos",
+ "meaning",
+ "example",
+ "eng_explanation"
+ )
+ else:
+ total = await jp.DefinitionJp.all().count()
+ wordlist = await jp.DefinitionJp.all().offset(offset).limit(page_size).values(
+ "word__text",
+ "pos",
+ "meaning",
+ "example",
+ )
+ return {
+ "total": total,
+ "data": wordlist
+ }
+
+
+@admin_router.post("/dict/search_word")
+async def search_word(
+ request: Request,
+ search_word: SearchWordRequest,
+ admin_user: Tuple[User, dict] = Depends(get_current_user),
+):
+ """
+ 查询单词
+ :param request: 请求体参数
+ :param search_word: Pydantic 模型校验:可提供词性筛选
+ :param admin_user:
+ :return:
+ """
+ if not admin_user[0].is_admin:
+ raise HTTPException(status_code=403, detail="非管理员,无权限访问")
+ # 筛选参数构造
+ filter_kwargs = {}
+ if search_word.pos:
+ filter_kwargs["pos"] = search_word.pos
+ if search_word.language == "fr":
+ try:
+ word_obj = await fr.WordlistFr.get(text=search_word.word)
+ except DoesNotExist:
+ raise HTTPException(status_code=400, detail=f"词条 {search_word.word} 不存在于法语词表中")
+ definitions = await word_obj.definitions.filter(**filter_kwargs)
+ result = [{
+ "id": d.id,
+ "word": word_obj.text,
+ "pos": d.pos,
+ "meaning": d.meaning,
+ "example": d.example,
+ "eng_explanation": d.eng_explanation
+ } for d in definitions]
+ return result
+ else:
+ try:
+ word_obj = await jp.WordlistJp.get(text=search_word.word)
+ except DoesNotExist:
+ raise HTTPException(status_code=400, detail=f"词条 {search_word.word} 不存在于日语词表中")
+ definitions = await word_obj.definitions.filter(**filter_kwargs)
+ result = [{
+ "id": d.id,
+ "word": word_obj.text,
+ "pos": d.pos,
+ "meaning": d.meaning,
+ "example": d.example
+ } for d in definitions]
+ return result
+
+
+@admin_router.post("/dict/adjust")
+async def adjust_dict(
+ request: Request,
+ updated_contents: UpdateWordSet,
+ admin_user: Tuple[User, dict] = Depends(get_current_user)
+):
+ """
+ 只关心更新的内容,不关心未改变的内容。
+ 批量更新 Definition 项,跳过失败项但记录错误。
+ :param request:
+ :param updated_contents:
+ :param admin_user:
+ :return:
+ """
+
+ if not admin_user[0].is_admin:
+ raise HTTPException(status_code=403, detail="非管理员,无权限访问")
+
+ if updated_contents.count() == 0:
+ raise HTTPException(status_code=422, detail="无改动信息")
+
+ errors = []
+
+ async def update_definition(update_word: UpdateWord) -> None:
+ # 检查词条是否存在
+ if update_word.language == 'fr':
+ word_entry = await fr.WordlistFr.get_or_none(id=update_word.id)
+ if not word_entry:
+ raise HTTPException(status_code=400, detail=f"词条 ID {update_word.id} 不存在于法语词表中")
+ update_obj = await fr.DefinitionFr.get_or_none(id=update_word.id)
+ else:
+ word_entry = await jp.WordlistJp.get_or_none(id=update_word.id)
+ if not word_entry:
+ raise HTTPException(status_code=400, detail=f"词条 ID {update_word.id} 不存在于日语词表中")
+ update_obj = await jp.DefinitionJp.get_or_none(id=update_word.id)
+
+ if not update_obj:
+ raise HTTPException(status_code=404, detail=f"定义 ID {update_word.id} 不存在")
+
+ # 获取更新字段
+ update_data = update_word.model_dump(exclude_unset=True)
+
+ for field, value in update_data.items():
+ if field != "id":
+ setattr(update_obj, field, value)
+
+ await update_obj.save()
+
+ for updated_content in updated_contents:
+ try:
+ await update_definition(updated_content)
+ except HTTPException as e:
+ errors.append({
+ "id": updated_content.id,
+ "error": e.detail
+ })
+
+ return {
+ "msg": "更新完成",
+ "success_count": updated_contents.count() - len(errors),
+ "fail_count": len(errors),
+ "errors": errors
+ }
+
+
+@admin_router.post("/dict/add")
+async def add_dict(
+ request: Request,
+ new_word: CreateWord,
+ admin_user: Tuple[User, dict] = Depends(get_current_user)
+) -> None:
+ if not admin_user[0].is_admin:
+ raise HTTPException(status_code=403, detail="非管理员,无权限访问")
+ if new_word.language == "fr":
+ cls_word, _ = await fr.WordlistFr.get_or_create(text=new_word.word)
+ new_definition, created = await fr.DefinitionFr.get_or_create(
+ word=cls_word,
+ pos=new_word.pos,
+ meaning=new_word.meaning,
+ example=new_word.example,
+ eng_explanation=new_word.eng_explanation
+ )
+ if not created:
+ raise HTTPException(status_code=409, detail="释义已存在")
+ elif new_word.language == "jp":
+ cls_word, _ = await jp.WordlistJp.get_or_create(text=new_word.word)
+ new_definition, created = await jp.DefinitionJp.get_or_create(
+ word=cls_word,
+ pos=new_word.pos,
+ meaning=new_word.meaning,
+ example=new_word.example,
+ )
+ if not created:
+ raise HTTPException(status_code=409, detail="释义已存在")
+ else:
+ raise HTTPException(status_code=400, detail="暂不支持语言类型")
diff --git a/app/api/admin/router.py b/app/api/admin/router.py
new file mode 100644
index 0000000..008357b
--- /dev/null
+++ b/app/api/admin/router.py
@@ -0,0 +1,3 @@
+from fastapi import APIRouter
+
+admin_router = APIRouter()
\ No newline at end of file
diff --git a/app/api/search.py b/app/api/search.py
new file mode 100644
index 0000000..22c040c
--- /dev/null
+++ b/app/api/search.py
@@ -0,0 +1,22 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+
+from app.models.fr import DefinitionFr
+from app.utils.security import get_current_user
+
+dict_search = APIRouter()
+
+
+@dict_search.get("/search")
+async def search(request: Request, lang_pref: str, query_word: str, user: Depends(get_current_user)):
+ word_content = await DefinitionFr.filter(
+ word__icontains=query_word,
+ lang_pref=lang_pref
+ ).values(
+ "word",
+ "part_of_speech",
+ "meaning",
+ "example"
+ )
+ if not word_content:
+ raise HTTPException(status_code=404, detail="Word not found")
+ return word_content
diff --git a/app/api/users.py b/app/api/users.py
new file mode 100644
index 0000000..c47a157
--- /dev/null
+++ b/app/api/users.py
@@ -0,0 +1,108 @@
+from fastapi import APIRouter, HTTPException, Depends, Request
+from typing import Tuple, Dict
+from datetime import datetime, timedelta, timezone
+from jose import jwt
+import redis.asyncio as redis
+
+from app.models.base import ReservedWords, User, Language
+from app.utils.security import verify_password, hash_password, validate_password, validate_username, get_current_user
+from settings import SECRET_KEY
+from app.core.redis import get_redis
+
+from app.schemas.user_schemas import UserIn, UserOut, UpdateUserRequest, UserLoginRequest
+
+users_router = APIRouter()
+
+
+@users_router.post("/register", response_model=UserOut)
+async def register(user_in: UserIn):
+ await validate_username(user_in.username)
+ await validate_password(user_in.password)
+
+ hashed_pwd = hash_password(user_in.password)
+
+ lang_pref = await Language.get(code=user_in.lang_pref)
+
+ new_user = await User.create(name=user_in.username,
+ pwd_hashed=hashed_pwd,
+ language=lang_pref, # 后续检查参数是否正确
+ portrait=user_in.portrait)
+ return new_user
+
+
+@users_router.post("/update")
+async def user_modification(updated_user: UpdateUserRequest, current_user: User = Depends(get_current_user)):
+ reserved_words = await ReservedWords.filter(category="username").values_list("reserved", flat=True)
+ # 验证当前密码
+ if not await verify_password(updated_user.current_password, current_user.password_hash):
+ raise HTTPException(status_code=400, detail="原密码错误")
+
+ # 修改用户名(如果提供)
+ if updated_user.new_username:
+ if updated_user.new_username.lower() in reserved_words:
+ raise HTTPException(status_code=400, detail="用户名为保留关键词,请更换")
+ current_user.username = updated_user.new_username
+
+ # 修改密码(如果提供)
+ if updated_user.new_password:
+ current_user.password_hash = hash_password(updated_user.new_password)
+
+
+@users_router.post("/login")
+async def user_login(user_in: UserLoginRequest):
+ user = await User.get_or_none(name=user_in.name)
+ if not user:
+ raise HTTPException(status_code=404, detail="用户不存在")
+
+ if not await verify_password(user_in.password, user.pwd_hashed):
+ raise HTTPException(status_code=400, detail="用户名或密码错误")
+
+ # token 中放置的信息
+ payload = {
+ "user_id": user.id,
+ "exp": datetime.now(timezone.utc) + timedelta(hours=2), # 设置过期时间
+ "is_admin" : user.is_admin,
+ }
+
+ token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
+
+ return {
+ "access_token": token,
+ "token_type": "bearer",
+ "user": {
+ "id": user.id,
+ "username": user.name,
+ "is_admin": user.is_admin
+ }
+ }
+
+
+@users_router.post("/logout")
+async def user_logout(request: Request,
+ redis_client: redis.Redis = Depends(get_redis),
+ user_data: Tuple[User, Dict] = Depends(get_current_user)):
+ user, payload = user_data
+ token = request.headers.get("Authorization")
+ if not token or not token.startswith("Bearer"):
+ raise HTTPException(status_code=401, detail="未登录")
+
+ # 检查 token
+ raw_token = token[7:]
+
+ exp = payload.get("exp")
+ now = datetime.now(timezone.utc).timestamp()
+ ttl = int(exp - now) if exp else 7200
+
+ # try:
+ # payload = jwt.decode(raw_token, SECRET_KEY, algorithms=["HS256"])
+ # exp = payload.get("exp")
+ # now = datetime.now(timezone.utc).timestamp()
+ # ttl = int(exp - now) if exp else 7200 # Time To Live: 黑名单生效时长
+ # except ExpiredSignatureError:
+ # raise HTTPException(status_code=401, detail="登录信息已过期")
+ # except JWTError:
+ # raise HTTPException(status_code=401, detail="无效 token")
+
+ await redis_client.setex(f"blacklist:{raw_token}", ttl, "true")
+
+ return {"message": "logout ok"}
diff --git a/app/core/__init__.py b/app/core/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/core/redis.py b/app/core/redis.py
new file mode 100644
index 0000000..3d725ac
--- /dev/null
+++ b/app/core/redis.py
@@ -0,0 +1,18 @@
+import redis.asyncio as redis
+from typing import AsyncGenerator
+
+# 全局 Redis 客户端
+redis_client: redis.Redis
+
+# 初始化 Redis(应用启动时调用)
+async def init_redis_pool():
+ global redis_client
+ redis_client = await redis.Redis(
+ host="localhost",
+ port=6379,
+ decode_responses=True, # 返回 str 而不是 Bytes
+ )
+
+# FastAPI 依赖注入用的获取方法
+async def get_redis() -> AsyncGenerator[redis.Redis, None]:
+ yield redis_client
\ No newline at end of file
diff --git a/app/models/__init__.py b/app/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/models/base.py b/app/models/base.py
new file mode 100644
index 0000000..780cf45
--- /dev/null
+++ b/app/models/base.py
@@ -0,0 +1,41 @@
+"""
+后续如果需要修改数据库 key 名字的,需要在 migrate 文件生成后手动修改 SQL 指令
+ e.g.
+ org: ALTER TABLE "entry" ADD COLUMN "term" VARCHAR(100);
+ new: ALTER TABLE "entry" RENAME COLUMN "word" TO "term";
+修改完成之后才能进行 upgrade
+
+后续如果要将 Foreign Key 修改为 ManyToMany,需要先保留当前 Key,新建一个,完成迁移后再删除
+ e.g.
+ entries = await WordEntry.all().prefetch_related("tag")
+"""
+
+from tortoise.models import Model
+from tortoise import fields
+
+
+class User(Model):
+ id = fields.IntField(pk=True)
+ name = fields.CharField(max_length=20, unique=True, description="用户名")
+ pwd_hashed = fields.CharField(max_length=60, description="密码")
+ portrait = fields.CharField(max_length=120, description="用户头像")
+ language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
+ is_admin = fields.BooleanField(default=False, description="管理员权限")
+
+ class Meta:
+ table = "users"
+
+
+class ReservedWords(Model):
+ id = fields.IntField(pk=True)
+ reserved = fields.CharField(max_length=20, description="保留词")
+ category = fields.CharField(max_length=20, default="username")
+
+ class Meta:
+ table = "reserved_words"
+
+
+class Language(Model):
+ id = fields.IntField(pk=True)
+ name = fields.CharField(max_length=30, unique=True) # e.g. "Japanese"
+ code = fields.CharField(max_length=10, unique=True) # e.g. "ja", "fr", "zh"
diff --git a/app/models/fr.py b/app/models/fr.py
new file mode 100644
index 0000000..42825ac
--- /dev/null
+++ b/app/models/fr.py
@@ -0,0 +1,153 @@
+from enum import Enum
+
+import pandas as pd
+from tortoise.models import Model
+from tortoise import fields
+from typing import Tuple, Type, TypeVar
+
+from app.schemas.admin_schemas import PosEnumFr
+
+sheet_name_fr = "法英中释义"
+
+
+class WordlistFr(Model):
+ id = fields.IntField(pk=True)
+ language = fields.CharField(max_length=20, description="单词语种")
+ text = fields.CharField(max_length=40, unique=True, description="单词")
+ definitions = fields.ReverseRelation("DefinitionFr")
+ attachments = fields.ReverseRelation("AttachmentsFr")
+
+ # attachment = fields.ForeignKeyField("models.Attachment", related_name="wordlists", on_delete=fields.CASCADE)
+ # source = fields.CharField(max_length=20, description="", null=True)
+ class Meta:
+ table = "wordlist_fr"
+
+ T = TypeVar("T", bound=Model)
+
+ @classmethod
+ async def update_or_create(cls: Type[T], **kwargs) -> Tuple[T, bool]:
+ print("传入参数为:", kwargs)
+ if not kwargs:
+ raise ValueError("必须提供至少一个字段作为参数")
+
+ created: bool = False
+
+ # 使用 kwargs 中第一个字段作为查找条件
+ first_key = next(iter(kwargs))
+ lookup = {first_key: kwargs[first_key]}
+
+ word = await cls.filter(**lookup).first() # 参数展开语法
+ if word:
+ for k, v in kwargs.items():
+ if k != first_key:
+ setattr(word, k, v)
+ await word.save()
+ else:
+ await cls.create(**kwargs)
+ created = True
+
+ return word, created
+
+
+class AttachmentFr(Model):
+ id = fields.IntField(pk=True)
+ word = fields.ForeignKeyField("models.WordlistFr", related_name="attachments", on_delete=fields.CASCADE)
+ yinbiao = fields.CharField(max_length=60, description="音标", null=True)
+ record = fields.CharField(max_length=120, description="发音", null=True)
+ pic = fields.CharField(max_length=120, description="配图", null=True)
+
+ class Meta:
+ table = "attachment_fr"
+
+
+class DefinitionFr(Model):
+ id = fields.IntField(pk=True)
+ word = fields.ForeignKeyField("models.WordlistFr", related_name="definitions", on_delete=fields.CASCADE)
+ pos = fields.CharEnumField(PosEnumFr, max_length=30) # ✅ 把词性放在释义层面
+ meaning = fields.TextField(description="单词释义") # 如:“学习”
+ example = fields.TextField(null=True, description="单词例句")
+ eng_explanation = fields.TextField(null=True, description="English explanation")
+
+ class Meta:
+ table = "definitions_fr"
+
+ @classmethod
+ async def init_from_xlsx(
+ cls,
+ filepath: str,
+ sheet_name: str
+ ):
+ """
+ Initiate the database from xlsx file. Only read in data without checking
+ whether the content already exists.
+ :param filepath: receive both relative or absolute path
+ :param sheet_name: specific sheet name inside the .xlsx file
+ :return: None
+ """
+ df = pd.read_excel(filepath, sheet_name=sheet_name, na_filter=True)
+ df.columns = [col.strip() for col in df.columns]
+ df.dropna(how="all", inplace=True)
+
+ # create_cnt = 0
+ DEF_COUNT = 1
+
+ for row in df.itertuples():
+ word = row.单词
+ cls_word = await WordlistFr.filter(text=word).first()
+ if cls_word is None:
+ print(f"未找到 word: {word}")
+ continue
+ pos = getattr(row, f"词性{DEF_COUNT}")
+ if pd.isna(pos):
+ continue
+ meaning = getattr(row, f"中文释义{DEF_COUNT}")
+ eng_exp = getattr(row, f"英语释义{DEF_COUNT}")
+ await DefinitionFr.create(
+ part_of_speech=pos,
+ meaning=meaning,
+ eng_explanation=eng_exp,
+ word=cls_word
+ )
+
+ # TODO revise the function (check update or create by id)
+ @classmethod
+ async def update_or_create_meaning(
+ cls,
+ word_obj,
+ target_language_obj,
+ part_of_speech: str,
+ meaning: str,
+ example: str = None,
+ eng_explanation: str = None,
+ ) -> tuple["DefinitionFr", bool]:
+ """
+ 查询某个单词是否已有该释义(依据四元组作为唯一标识),存在则更新,不存在则新增。
+ 返回:(对象, 是否为新创建)
+ """
+ query = {
+ "word": word_obj,
+ "target_language": target_language_obj,
+ "part_of_speech": part_of_speech,
+ "meaning": meaning
+ }
+
+ obj = await cls.filter(**query).first()
+ created = False
+
+ if obj:
+ # 可更新其他字段
+ obj.example = example
+ obj.eng_explanation = eng_explanation
+ await obj.save()
+ else:
+ obj = await cls.create(
+ word=word_obj,
+ target_language=target_language_obj,
+ part_of_speech=part_of_speech,
+ meaning=meaning,
+ example=example,
+ eng_explanation=eng_explanation,
+ )
+ created = True
+
+ return obj, created
diff --git a/app/models/jp.py b/app/models/jp.py
new file mode 100644
index 0000000..671c925
--- /dev/null
+++ b/app/models/jp.py
@@ -0,0 +1,73 @@
+from __future__ import annotations
+
+from enum import Enum
+from app.schemas.admin_schemas import PosEnumJp
+
+import pandas as pd
+from tortoise.exceptions import DoesNotExist, MultipleObjectsReturned
+from tortoise.models import Model
+from tortoise import fields
+from typing import Tuple, TYPE_CHECKING, TypeVar, Type, Optional
+
+sheet_name_jp = "日汉释义"
+
+
+# noinspection PyArgumentList
+class WordlistJp(Model):
+ id = fields.IntField(pk=True)
+ text = fields.CharField(max_length=40, description="单词")
+ definitions = fields.ReverseRelation("DefinitionJp")
+ attachments = fields.ReverseRelation("AttachmentsJp")
+
+ class Meta:
+ table = "wordlist_jp"
+
+ T = TypeVar("T")
+
+ @classmethod
+ async def init_from_xlsx(cls, filepath: str, sheet_name: str) -> None:
+ df = pd.read_excel(filepath, sheet_name=sheet_name)
+ df.columns = [col.strip() for col in df.columns]
+ df.dropna(how="all", inplace=True)
+
+ for row in df.itertuples():
+ word = row.单词
+ await cls.create(
+ text=word,
+ )
+
+ @classmethod
+ async def update_and_create(cls, text: str) -> Tuple[WordlistJp, bool]:
+ created = False
+ try:
+ word = await cls.get(text=text)
+ except DoesNotExist:
+ word = await cls.create(text=text)
+ created = True
+ else:
+ word.text = text
+ await word.save()
+ return word, created
+
+
+class AttachmentJp(Model):
+ id = fields.IntField(pk=True)
+ word = fields.ForeignKeyField("models.WordlistJp", related_name="attachments", on_delete=fields.CASCADE)
+ hiragana = fields.CharField(max_length=60, description="假名", null=True)
+ romaji = fields.TextField(null=True, description="罗马字")
+ record = fields.CharField(max_length=120, description="发音", null=True)
+ pic = fields.CharField(max_length=120, description="配图", null=True)
+
+ class Meta:
+ table = "attachment_jp"
+
+
+class DefinitionJp(Model):
+ id = fields.IntField(pk=True)
+ word = fields.ForeignKeyField("models.WordlistJp", related_name="definitions", on_delete=fields.CASCADE)
+ meaning = fields.TextField(description="单词释义")
+ example = fields.TextField(null=True, description="单词例句")
+ pos = fields.CharEnumField(PosEnumJp, max_length=30, null=True)
+
+ class Meta:
+ table = "definitions_jp"
diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/schemas/admin_schemas.py b/app/schemas/admin_schemas.py
new file mode 100644
index 0000000..e8c5dfd
--- /dev/null
+++ b/app/schemas/admin_schemas.py
@@ -0,0 +1,104 @@
+from enum import Enum
+
+from pydantic import BaseModel, validator, field_validator, Field
+from typing import Optional, Literal, List
+
+from tortoise.exceptions import DoesNotExist
+
+from app.models.fr import WordlistFr
+
+
+class PosEnumFr(str, Enum):
+ # noun
+ n = "n."
+ n_f = "n.f."
+ n_f_pl = "n.f.pl."
+ n_m = "n.m."
+ n_m_pl = "n.m.pl."
+ # verb
+ v = "v."
+ v_t = "v.t."
+ v_i = "v.i."
+ v_pr = "v.pr."
+ v_t_i = "v.t./v.i."
+
+ adj = "adj." # adj
+ adv = "adv." # adv
+ prep = "prep." # prep
+ pron = "pron." # pron
+ conj = "conj."
+ interj = "interj."
+ chauff = "chauff"
+
+
+class PosEnumJp(str, Enum):
+ noun = "名词"
+ adj = "形容词"
+ adj_v = "形容动词"
+ v1 = "一段动词"
+ v5 = "五段动词"
+ help = "助词"
+
+
+class CreateWord(BaseModel):
+ word: str
+ language: Literal["fr", "jp"]
+ pos: str = Field(title="词性", description="必须符合对应语言词性枚举")
+ meaning: str
+ example: Optional[str]
+ eng_explanation: Optional[str]
+
+ class Config:
+ orm_mode = True
+ title = "接受新词条模型"
+
+ @classmethod
+ @field_validator("eng_explanation")
+ def validate_eng_explanation(cls, v):
+ if cls.language is "jp" and v:
+ raise ValueError("Japanese word has no English explanation")
+ if cls.language is "fr" and v is None or v == "":
+ raise ValueError("French word must have English explanation")
+ return v
+
+ @classmethod
+ @field_validator("pos")
+ def validate_pos(cls, v):
+ if cls.language is "fr" and v not in PosEnumFr:
+ raise ValueError("Pos is not a valid type")
+ if cls.language is "jp" and v not in PosEnumJp:
+ raise ValueError("Pos is not a valid type")
+ return v
+
+
+class UpdateWord(BaseModel):
+ id: int
+ word: str
+ language: Literal["fr", "jp"]
+ eng_explanation: Optional[str]
+ example: Optional[str]
+ pos: Optional[str]
+ meaning: Optional[str]
+
+ class Config:
+ orm_mode = True # 允许从 ORM 实例中提取字段,而不仅限于 dict 类型
+
+
+class UpdateWordSet(List[UpdateWord]):
+ pass
+
+
+class SearchWordRequest(BaseModel):
+ word: str
+ language: Literal["fr", "jp"]
+ pos: Optional[str]
+
+ @classmethod
+ @field_validator("pos")
+ def validate_pos(cls, v):
+ if v is not None:
+ if cls.language is "fr" and v not in PosEnumFr:
+ raise ValueError("Pos is not a valid type")
+ if cls.language is "jp" and v not in PosEnumJp:
+ raise ValueError("Pos is not a valid type")
+ return v
diff --git a/app/schemas/user_schemas.py b/app/schemas/user_schemas.py
new file mode 100644
index 0000000..93bae23
--- /dev/null
+++ b/app/schemas/user_schemas.py
@@ -0,0 +1,59 @@
+from pydantic import BaseModel
+from typing import Literal
+
+default_portrait_url = '#'
+
+
+class UserIn(BaseModel):
+ username: str
+ password: str
+ lang_pref: Literal['jp', 'fr', 'private'] = "private"
+ portrait: str = default_portrait_url
+
+ # @field_validator('username')
+ # @classmethod
+ # def validate_username(cls, v):
+ # if not (3 <= len(v) <= 20):
+ # raise ValueError("用户名长度必须在3到20个字符之间")
+ # if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', v):
+ # raise ValueError("用户名只能包含字母、数字和下划线,且不能以数字开头")
+ # return v
+
+ # 校验密码
+ # @field_validator("password")
+ # @classmethod
+ # def verify_password(cls, password: str):
+ # if len(password) < 6 or len(password) > 20:
+ # raise ValueError("Password must be between 6 and 20 characters")
+ # # 检查是否包含至少一个数字
+ # if not re.search(r'\d', password):
+ # raise ValueError('密码必须包含至少一个数字')
+ # # 检查是否包含非法特殊字符(只允许字母和数字)
+ # if re.search(r'[^a-zA-Z0-9]', password):
+ # raise ValueError('密码不能包含特殊字符')
+ # return password
+
+ # @field_validator('lang_pref')
+ # @classmethod
+ # def validate_lang_pref(cls, v):
+ # assert v in ('jp', 'en')
+ # return v
+
+
+class UserOut(BaseModel):
+ name: str
+ potrait: str = '#'
+
+
+class UpdateUserRequest(BaseModel):
+ current_password: str
+ new_username: str
+ new_password: str
+ new_language: Literal["jp", "fr", "private"] = "private"
+
+ # lang_pref: str = "jp"
+
+
+class UserLoginRequest(BaseModel):
+ name: str
+ password: str
diff --git a/app/schemas/word_schemas.py b/app/schemas/word_schemas.py
new file mode 100644
index 0000000..f80e7b5
--- /dev/null
+++ b/app/schemas/word_schemas.py
@@ -0,0 +1 @@
+from pydantic import BaseModel
\ No newline at end of file
diff --git a/app/utils/__init__.py b/app/utils/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/utils/security.py b/app/utils/security.py
new file mode 100644
index 0000000..fb206d8
--- /dev/null
+++ b/app/utils/security.py
@@ -0,0 +1,135 @@
+import re
+import bcrypt
+from contextlib import asynccontextmanager
+from datetime import datetime, timezone
+from typing import Tuple, Dict, Annotated
+from fastapi import HTTPException, Request, Depends
+from fastapi.security import OAuth2PasswordBearer
+from jose import jwt, JWTError, ExpiredSignatureError
+import redis.asyncio as redis
+
+from app.models.base import ReservedWords, User
+
+from settings import SECRET_KEY
+
+redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
+
+
+async def validate_username(username: str):
+ # 长度限制
+ if not (3 <= len(username) <= 20):
+ raise ValueError('用户名长度必须在3到20个字符之间')
+ # 只能包含字母、数字和下划线
+ if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', username):
+ raise ValueError('用户名只能包含字母、数字和下划线,且不能以数字开头')
+ # 保留词
+ reserved_words = await ReservedWords.filter(category="username").values_list("reserved", flat=True)
+ if username.lower() in {w.lower() for w in reserved_words}:
+ raise HTTPException(status_code=400, detail="用户名为保留关键词,请更换")
+
+
+async def validate_password(password: str):
+ if not (6 <= len(password) <= 20):
+ raise HTTPException(status_code=400, detail="密码长度必须在6到20之间")
+ if not re.search(r'\d', password):
+ raise HTTPException(status_code=400, detail="密码必须包含至少一个数字")
+ if re.search(r'[^a-zA-Z0-9]', password):
+ raise HTTPException(status_code=400, detail="密码不能包含特殊字符,只能包含字母和数字")
+
+
+# 登陆校验
+async def verify_password(raw_password: str, hashed_password: str) -> bool:
+ """
+ 校验用户登录时输入的密码是否与数据库中保存的加密密码匹配。
+
+ 参数:
+ raw_password: 用户登录时输入的明文密码
+ hashed_password: 数据库存储的加密密码字符串(password_hash)
+
+ 返回:
+ 如果密码匹配,返回 True;否则返回 False
+ """
+ return bcrypt.checkpw(raw_password.encode("utf-8"), hashed_password.encode("utf-8"))
+
+
+# 注册或修改密码时加密
+def hash_password(raw_password: str) -> str:
+ """
+ 将用户输入的明文密码进行加密(哈希)后返回字符串,用于保存到数据库中。
+
+ 参数:
+ raw_password: 用户输入的明文密码
+
+ 返回:
+ 加密后的密码字符串(含盐),可直接保存到数据库字段如 password_hash 中
+ """
+ salt = bcrypt.gensalt()
+ return bcrypt.hashpw(raw_password.encode("utf-8"), salt).decode("utf-8")
+
+
+@asynccontextmanager
+async def redis_pool():
+ client = redis.Redis(host="localhost", port=6379, decode_responses=True)
+ yield client
+
+
+# 从请求头中获取当前用户信息
+async def get_current_user(request: Request) -> Tuple[User, Dict]:
+ # 从 headers 中获取 Authorization 字段
+ token = request.headers.get("Authorization")
+
+ # 检查 token 是否存在且格式正确(Bearer 开头)
+ if not token or not token.startswith("Bearer "):
+ raise HTTPException(status_code=401, detail="未登录")
+
+ raw_token = token[7:]
+
+ # 黑名单校验
+ if await redis_client.get(f"blacklist:{raw_token}") == "true":
+ raise HTTPException(status_code=401, detail="token 已失效")
+
+ try:
+ # 去掉 "Bearer " 前缀后解析 JWT
+ payload = jwt.decode(token[7:], SECRET_KEY, algorithms=["HS256"]) # 自动校验exp
+ user_id = payload.get("user_id")
+ except ExpiredSignatureError:
+ # token 信息中的 exp 已经过期
+ raise HTTPException(status_code=401, detail="登陆信息已过期")
+ except JWTError:
+ # JWT 格式错误或校验失败
+ raise HTTPException(status_code=401, detail="无效的令牌")
+
+ # 从数据库查找对应用户
+ user = await User.get_or_none(id=user_id)
+ if not user:
+ raise HTTPException(status_code=401, detail="用户不存在")
+
+ return user, payload
+
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/logout")
+ALGORITHM = "HS256"
+
+
+async def get_current_user_with_OAuth(token: Annotated[str, Depends(oauth2_scheme)]):
+ # TODO OAuth验证
+ # Redis 黑名单检查
+ blacklisted = await redis_client.get(f"blacklist:{token}")
+ if blacklisted:
+ raise HTTPException(status_code=401, detail="Token 已失效")
+
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ user_id = payload.get("user_id")
+ if not user_id:
+ raise HTTPException(status_code=401, detail="无效 token")
+ user = await User.get_or_none(id=user_id)
+ if not user:
+ raise HTTPException(status_code=401, detail="用户不存在")
+
+ return user, payload
+ except ExpiredSignatureError:
+ raise HTTPException(status_code=401, detail="token 已过期")
+ except JWTError:
+ raise HTTPException(status_code=401, detail="")
+
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..8b6f502
--- /dev/null
+++ b/main.py
@@ -0,0 +1,31 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+import uvicorn
+from tortoise.contrib.fastapi import register_tortoise
+
+from settings import TORTOISE_ORM
+from app.api.users import users_router
+from app.api.admin.router import admin_router
+from app.core.redis import init_redis_pool
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ await init_redis_pool()
+ yield
+ # 可以加 await redis_client.close() 清理资源
+
+
+app = FastAPI(lifespan=lifespan)
+
+register_tortoise(
+ app=app,
+ config=TORTOISE_ORM,
+)
+
+app.include_router(users_router, tags=["User API"], prefix="/users")
+app.include_router(admin_router, tags=["Administrator API"], prefix="/admin")
+
+if __name__ == '__main__':
+ uvicorn.run("main:app", host='127.0.0.1', port=8000, reload=True)
diff --git a/migrations/models/10_20250803090700_update.py b/migrations/models/10_20250803090700_update.py
new file mode 100644
index 0000000..4931180
--- /dev/null
+++ b/migrations/models/10_20250803090700_update.py
@@ -0,0 +1,11 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `wordlist` RENAME TO `fr_wordlist`;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `fr_wordlist` RENAME TO `wordlist`;"""
diff --git a/migrations/models/11_20250803092810_update.py b/migrations/models/11_20250803092810_update.py
new file mode 100644
index 0000000..6d5ea79
--- /dev/null
+++ b/migrations/models/11_20250803092810_update.py
@@ -0,0 +1,18 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ RENAME TABLE 'fr_wordlist' TO 'wordlist_fr';
+ CREATE TABLE IF NOT EXISTS `wordlist_jp` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `text` VARCHAR(40) NOT NULL COMMENT '单词'
+) CHARACTER SET utf8mb4;
+ """
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ DROP TABLE IF EXISTS `attachment_fr`;
+ DROP TABLE IF EXISTS `wordlist_jp`;
+ DROP TABLE IF EXISTS `wordlist_fr`;"""
diff --git a/migrations/models/12_20250803094004_update.py b/migrations/models/12_20250803094004_update.py
new file mode 100644
index 0000000..90fc3e2
--- /dev/null
+++ b/migrations/models/12_20250803094004_update.py
@@ -0,0 +1,19 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ CREATE TABLE IF NOT EXISTS `attachment_jp` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `hiragana` VARCHAR(60) COMMENT '假名',
+ `romaji` LONGTEXT COMMENT '罗马字',
+ `record` VARCHAR(120) COMMENT '发音',
+ `pic` VARCHAR(120) COMMENT '配图',
+ `word_id` INT NOT NULL,
+ CONSTRAINT `fk_attachme_wordlist_c6aaf942` FOREIGN KEY (`word_id`) REFERENCES `wordlist_jp` (`id`) ON DELETE CASCADE
+) CHARACTER SET utf8mb4;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ DROP TABLE IF EXISTS `attachment_jp`;"""
diff --git a/migrations/models/13_20250805203751_update.py b/migrations/models/13_20250805203751_update.py
new file mode 100644
index 0000000..4e60601
--- /dev/null
+++ b/migrations/models/13_20250805203751_update.py
@@ -0,0 +1,21 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ RENAME TABLE `definitions` TO `definitions_fr`;
+ CREATE TABLE IF NOT EXISTS `definition_jp` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `meaning` LONGTEXT NOT NULL COMMENT '单词释义',
+ `example` LONGTEXT COMMENT '单词例句',
+ `pos` VARCHAR(30) COMMENT 'noun: 名词\nadj: 形容词\nadj_v: 形容动词\nv1: 一段动词\nv5: 五段动词\nhelp: 助词',
+ `word_id` INT NOT NULL,
+ CONSTRAINT `fk_definiti_wordlist_9093dbd0` FOREIGN KEY (`word_id`) REFERENCES `wordlist_jp` (`id`) ON DELETE CASCADE
+) CHARACTER SET utf8mb4;
+ DROP TABLE IF EXISTS `definitions`;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ DROP TABLE IF EXISTS `definition_jp`;
+ DROP TABLE IF EXISTS `definitions_fr`;"""
diff --git a/migrations/models/14_20250805204351_update.py b/migrations/models/14_20250805204351_update.py
new file mode 100644
index 0000000..7f1bda1
--- /dev/null
+++ b/migrations/models/14_20250805204351_update.py
@@ -0,0 +1,11 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definition_jp` RENAME TO `definitions_jp`;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definitions_jp` RENAME TO `definition_jp`;"""
diff --git a/migrations/models/15_20250805205550_update.py b/migrations/models/15_20250805205550_update.py
new file mode 100644
index 0000000..f21b79f
--- /dev/null
+++ b/migrations/models/15_20250805205550_update.py
@@ -0,0 +1,13 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definitions_fr` DROP FOREIGN KEY `fk_definiti_language_9d3d9ce0`;
+ ALTER TABLE `definitions_fr` DROP COLUMN `target_language_id`;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definitions_fr` ADD `target_language_id` INT NOT NULL;
+ ALTER TABLE `definitions_fr` ADD CONSTRAINT `fk_definiti_language_82dc7bd0` FOREIGN KEY (`target_language_id`) REFERENCES `language` (`id`) ON DELETE CASCADE;"""
diff --git a/migrations/models/16_20250806104900_update.py b/migrations/models/16_20250806104900_update.py
new file mode 100644
index 0000000..017c230
--- /dev/null
+++ b/migrations/models/16_20250806104900_update.py
@@ -0,0 +1,11 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definitions_fr` RENAME COLUMN `part_of_speech` TO `pos`;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ ALTER TABLE `definitions_fr` RENAME COLUMN `pos` TO `part_of_speech`;"""
diff --git a/migrations/models/9_20250802004957_None.py b/migrations/models/9_20250802004957_None.py
new file mode 100644
index 0000000..079669a
--- /dev/null
+++ b/migrations/models/9_20250802004957_None.py
@@ -0,0 +1,59 @@
+from tortoise import BaseDBAsyncClient
+
+
+async def upgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ CREATE TABLE IF NOT EXISTS `language` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `name` VARCHAR(30) NOT NULL UNIQUE,
+ `code` VARCHAR(10) NOT NULL UNIQUE
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `reserved_words` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `reserved` VARCHAR(20) NOT NULL COMMENT '保留词',
+ `category` VARCHAR(20) NOT NULL DEFAULT 'username'
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `users` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `name` VARCHAR(20) NOT NULL UNIQUE COMMENT '用户名',
+ `pwd_hashed` VARCHAR(60) NOT NULL COMMENT '密码',
+ `portrait` VARCHAR(120) NOT NULL COMMENT '用户头像',
+ `is_admin` BOOL NOT NULL COMMENT '管理员权限' DEFAULT 0,
+ `language_id` INT NOT NULL,
+ CONSTRAINT `fk_users_language_d51b5368` FOREIGN KEY (`language_id`) REFERENCES `language` (`id`) ON DELETE CASCADE
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `wordlist` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `language` VARCHAR(20) NOT NULL COMMENT '单词语种',
+ `text` VARCHAR(40) NOT NULL UNIQUE COMMENT '单词'
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `attachment` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `yinbiao` VARCHAR(60) COMMENT '音标',
+ `record` VARCHAR(120) COMMENT '发音',
+ `pic` VARCHAR(120) COMMENT '配图',
+ `word_id` INT NOT NULL,
+ CONSTRAINT `fk_attachme_wordlist_ca554ed9` FOREIGN KEY (`word_id`) REFERENCES `wordlist` (`id`) ON DELETE CASCADE
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `definitions` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `part_of_speech` VARCHAR(20) COMMENT '词性',
+ `meaning` LONGTEXT NOT NULL COMMENT '单词释义',
+ `example` LONGTEXT COMMENT '单词例句',
+ `eng_explanation` LONGTEXT COMMENT 'English explanation',
+ `target_language_id` INT NOT NULL,
+ `word_id` INT NOT NULL,
+ CONSTRAINT `fk_definiti_language_9d3d9ce0` FOREIGN KEY (`target_language_id`) REFERENCES `language` (`id`) ON DELETE CASCADE,
+ CONSTRAINT `fk_definiti_wordlist_551d1753` FOREIGN KEY (`word_id`) REFERENCES `wordlist` (`id`) ON DELETE CASCADE
+) CHARACTER SET utf8mb4;
+CREATE TABLE IF NOT EXISTS `aerich` (
+ `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
+ `version` VARCHAR(255) NOT NULL,
+ `app` VARCHAR(100) NOT NULL,
+ `content` JSON NOT NULL
+) CHARACTER SET utf8mb4;"""
+
+
+async def downgrade(db: BaseDBAsyncClient) -> str:
+ return """
+ """
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..1ab7dd1
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,4 @@
+[tool.aerich]
+tortoise_orm = "settings.TORTOISE_ORM"
+location = "./migrations"
+src_folder = "./."
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c9c7a21
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,122 @@
+booktype
+aerich==0.9.1
+aiomysql==0.2.0
+aiosqlite==0.21.0
+altgraph==0.17.4
+annotated-types==0.7.0
+anyio==4.9.0
+appnope==0.1.4
+asttokens==3.0.0
+asyncclick==8.1.8.0
+atlastk==0.13.5
+attrs==25.3.0
+backcall==0.2.0
+bcrypt==4.3.0
+beautifulsoup4==4.13.4
+bleach==6.2.0
+certifi==2025.7.14
+charset-normalizer==3.4.2
+click==8.2.1
+contourpy==1.3.2
+cycler==0.12.1
+decorator==5.2.1
+defusedxml==0.7.1
+dictdiffer==0.9.0
+docopt==0.6.2
+ecdsa==0.19.1
+et_xmlfile==2.0.0
+executing==2.2.0
+fastapi==0.116.1
+fastjsonschema==2.21.1
+fonttools==4.58.0
+greenlet==3.2.3
+h11==0.16.0
+idna==3.10
+ipython==8.12.3
+iso8601==2.1.0
+jedi==0.19.2
+jieba==0.42.1
+Jinja2==3.1.6
+json5==0.12.0
+jsonschema==4.25.0
+jsonschema-specifications==2025.4.1
+jupyter_client==8.6.3
+jupyter_core==5.8.1
+jupyterlab_pygments==0.3.0
+kiwisolver==1.4.8
+macholib==1.16.3
+MarkupSafe==3.0.2
+matplotlib==3.10.3
+matplotlib-inline==0.1.7
+mistune==3.1.3
+nbclient==0.10.2
+nbconvert==7.16.6
+nbformat==5.10.4
+numpy==2.2.5
+openpyxl==3.1.5
+outcome==1.3.0.post0
+packaging==25.0
+pandas==2.2.3
+pandas-stubs==2.3.0.250703
+pandocfilters==1.5.1
+parso==0.8.4
+pexpect==4.9.0
+pickleshare==0.7.5
+pillow==11.1.0
+pipreqs==0.5.0
+platformdirs==4.3.8
+playwright==1.53.0
+prompt_toolkit==3.0.51
+ptyprocess==0.7.0
+pure_eval==0.2.3
+pyasn1==0.6.1
+pydantic==2.11.7
+pydantic_core==2.33.2
+pyee==13.0.0
+Pygments==2.19.2
+pyinstaller==6.14.2
+pyinstaller-hooks-contrib==2025.6
+PyMySQL==1.1.1
+pyparsing==3.2.3
+pypika-tortoise==0.6.1
+PySocks==1.7.1
+python-dateutil==2.9.0.post0
+python-jose==3.5.0
+python-multipart==0.0.20
+pytz==2025.2
+PyYAML==6.0.2
+pyzmq==27.0.1
+redis==6.2.0
+referencing==0.36.2
+requests==2.32.4
+rpds-py==0.26.0
+rsa==4.9.1
+selenium==4.34.2
+setuptools==80.9.0
+six==1.17.0
+sniffio==1.3.1
+sortedcontainers==2.4.0
+soupsieve==2.7
+stack-data==0.6.3
+starlette==0.47.1
+tinycss2==1.4.0
+tornado==6.5.1
+tortoise==0.1.1
+tortoise-orm==0.25.1
+traitlets==5.14.3
+trio==0.30.0
+trio-websocket==0.12.2
+typing-inspection==0.4.1
+typing_extensions==4.14.0
+tzdata==2025.2
+urllib3==2.5.0
+uvicorn==0.35.0
+wcwidth==0.2.13
+webencodings==0.5.1
+websocket-client==1.8.0
+wheel==0.45.1
+wordcloud==1.9.4
+WsgiDAV==4.3.3
+wsproto==1.2.0
+xlrd==2.0.1
+yarg==0.1.9
diff --git a/settings.py b/settings.py
new file mode 100644
index 0000000..bfd59af
--- /dev/null
+++ b/settings.py
@@ -0,0 +1,35 @@
+TORTOISE_ORM = {
+ 'connections': {
+ 'default': {
+ # 'engine': 'tortoise.backends.asyncpg', PostgreSQL
+ 'engine': 'tortoise.backends.mysql', # MySQL or Mariadb
+ 'credentials': {
+ 'host': '127.0.0.1',
+ 'port': '3306',
+ 'user': 'root',
+ 'password': 'enterprise',
+ 'database': 'dict',
+ 'minsize': 1,
+ 'maxsize': 5,
+ 'charset': 'utf8mb4',
+ "echo": True
+ }
+ },
+ },
+ 'apps': {
+ 'models': {
+ 'models': [
+ 'app.models.base',
+ 'app.models.fr',
+ 'app.models.jp',
+ 'aerich.models' # aerich自带模型类(必须填入)
+ ],
+ 'default_connection': 'default',
+
+ }
+ },
+ 'use_tz': False,
+ 'timezone': 'Asia/Shanghai'
+}
+
+SECRET_KEY = "asdasdasd-odjfnsodfnosidnfdf-0oq2j01j0jf0i1ej0fij10fd"