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"