Initial Commit
This commit is contained in:
commit
6a01fc2ab5
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="dict@localhost" uuid="002414ae-d165-4dd5-b083-7d2a09fa7184">
|
||||||
|
<driver-ref>mysql.8</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:mysql://localhost:3306/dict</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.12 (dict_server)" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.12 (dict_server)" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (dict_server)" project-jdk-type="Python SDK" />
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/dict_server.iml" filepath="$PROJECT_DIR$/.idea/dict_server.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/migrations/models/11_20250803092810_update.py" dialect="MySQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/migrations/models/16_20250806104900_update.py" dialect="GenericSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
./app/utils/security.py: get_current_user()新版本尚未启用
|
||||||
|
|
@ -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="暂不支持语言类型")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
admin_router = APIRouter()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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="<UNK>", 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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
@ -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="")
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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;"""
|
||||||
|
|
@ -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`;"""
|
||||||
|
|
@ -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 """
|
||||||
|
"""
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
[tool.aerich]
|
||||||
|
tortoise_orm = "settings.TORTOISE_ORM"
|
||||||
|
location = "./migrations"
|
||||||
|
src_folder = "./."
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
Loading…
Reference in New Issue