Initial Commit

This commit is contained in:
Miyamizu-MitsuhaSang 2025-08-07 23:55:37 +08:00
commit 6a01fc2ab5
40 changed files with 1650 additions and 0 deletions

321
.gitignore vendored Normal file
View File

@ -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

12
.idea/dataSources.xml Normal file
View File

@ -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>

10
.idea/dict_server.iml Normal file
View File

@ -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>

View File

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml Normal file
View File

@ -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>

8
.idea/modules.xml Normal file
View File

@ -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>

7
.idea/sqldialects.xml Normal file
View File

@ -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>

6
.idea/vcs.xml Normal file
View File

@ -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>

1
README.txt Normal file
View File

@ -0,0 +1 @@
./app/utils/security.py: get_current_user()新版本尚未启用

0
app/__init__.py Normal file
View File

0
app/api/__init__.py Normal file
View File

View File

200
app/api/admin/dict.py Normal file
View File

@ -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="暂不支持语言类型")

3
app/api/admin/router.py Normal file
View File

@ -0,0 +1,3 @@
from fastapi import APIRouter
admin_router = APIRouter()

22
app/api/search.py Normal file
View File

@ -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

108
app/api/users.py Normal file
View File

@ -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
app/core/__init__.py Normal file
View File

18
app/core/redis.py Normal file
View File

@ -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
app/models/__init__.py Normal file
View File

41
app/models/base.py Normal file
View File

@ -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"

153
app/models/fr.py Normal file
View File

@ -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

73
app/models/jp.py Normal file
View File

@ -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
app/schemas/__init__.py Normal file
View File

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
from pydantic import BaseModel

0
app/utils/__init__.py Normal file
View File

135
app/utils/security.py Normal file
View File

@ -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="")

31
main.py Normal file
View File

@ -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)

View File

@ -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`;"""

View File

@ -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`;"""

View File

@ -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`;"""

View File

@ -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`;"""

View File

@ -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`;"""

View File

@ -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;"""

View File

@ -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`;"""

View File

@ -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 """
"""

4
pyproject.toml Normal file
View File

@ -0,0 +1,4 @@
[tool.aerich]
tortoise_orm = "settings.TORTOISE_ORM"
location = "./migrations"
src_folder = "./."

122
requirements.txt Normal file
View File

@ -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

35
settings.py Normal file
View File

@ -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"