忘记密码接口

This commit is contained in:
Miyamizu-MitsuhaSang 2025-09-26 12:00:25 +08:00
parent 3ff6d4a3da
commit fc053c239d
20 changed files with 715 additions and 110 deletions

View File

@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (dict_server) (2)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.12 (dict_server)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

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

View File

@ -3,5 +3,5 @@
<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) (2)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (dict_server)" project-jdk-type="Python SDK" />
</project>

View File

@ -1,21 +1,59 @@
from typing import Literal, List
import jaconv
import pykakasi
from fastapi import APIRouter, Depends, HTTPException, Request
from app.models import DefinitionJp
from app.models import DefinitionJp, CommentFr, CommentJp
from app.models.fr import DefinitionFr
from app.schemas.comment_schemas import CommentSet
from app.schemas.search_schemas import SearchRequest, SearchResponse, SearchItemFr, SearchItemJp
from app.utils.all_kana import all_in_kana
from app.utils.autocomplete import suggest_autocomplete
from app.utils.security import get_current_user
from app.utils.textnorm import normalize_text
from scripts.update_jp import normalize_jp_text
dict_search = APIRouter()
async def __get_comments(
__query_word: str,
language: Literal["jp", "fr"]
) -> CommentSet:
if language == "fr":
comments = await (
CommentFr
.filter(comment_word__word=__query_word)
.select_related("user")
.order_by("-created_at")
)
commentlist = CommentSet(
comments=[
(
comment.user.id,
comment.user.name,
comment.comment_text
) for comment in comments
]
)
return commentlist
else:
comments = await (
CommentJp
.filter(comment_word__word=__query_word)
.select_related("user")
.order_by("-created_at")
)
commentlist = CommentSet(
comments=[
(
comment.user.id,
comment.user.name,
comment.comment_text,
) for comment in comments
]
)
return commentlist
@dict_search.post("/search", response_model=SearchResponse)
async def search(request: Request, body: SearchRequest, user=Depends(get_current_user)):
"""

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

View File

@ -1,25 +1,28 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from typing import Tuple, Dict
from datetime import datetime, timedelta, timezone
from jose import jwt
from typing import Tuple, Dict
import redis.asyncio as redis
from fastapi import APIRouter, HTTPException, Depends, Request
from jose import jwt
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 settings
from app.api.user.user_schemas import UserIn, UpdateUserRequest, UserLoginRequest, UserResetPhoneRequest, \
UserDoesNotExistsError, VerifyCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest
from app.core.redis import get_redis
from app.schemas.user_schemas import UserIn, UserOut, UpdateUserRequest, UserLoginRequest
from app.models.base import ReservedWords, User, Language
from app.utils.security import get_current_user
from settings import settings
from . import service
from .service import hash_password, send_email_code
users_router = APIRouter()
@users_router.post("/register")
async def register(user_in: UserIn):
await validate_username(user_in.username)
await validate_password(user_in.password)
await service.validate_username(user_in.username)
await service.validate_password(user_in.password)
hashed_pwd = hash_password(user_in.password)
hashed_pwd = service.hash_password(user_in.password)
lang_pref = await Language.get(code=user_in.lang_pref)
@ -48,7 +51,7 @@ async def user_modification(updated_user: UpdateUserRequest, current_user: 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):
if not await service.verify_password(updated_user.current_password, current_user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
# 修改用户名(如果提供)
@ -68,7 +71,7 @@ async def user_login(user_in: UserLoginRequest):
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if not await verify_password(user_in.password, user.pwd_hashed):
if not await service.verify_password(user_in.password, user.pwd_hashed):
raise HTTPException(status_code=400, detail="用户名或密码错误")
# token 中放置的信息
@ -120,3 +123,85 @@ async def user_logout(request: Request,
await redis_client.setex(f"blacklist:{raw_token}", ttl, "true")
return {"message": "logout ok"}
#后续通过参数合并
@users_router.post("/auth/forget-password/phone", deprecated=True)
async def forget_password(request: Request, user_request: UserResetPhoneRequest):
encrypted_phone = request.app.state.phone_encrypto.encrypt(phone=user_request.phone)
user = await User.get_or_none(encrypted_phone=encrypted_phone)
if not user:
raise UserDoesNotExistsError()
redis = request.app.state.Redis
code = service.generate_code()
await service.save_code_redis(redis, phone=user_request.phone_number, code=code)
# TODO 短信服务
print(f"[DEBUG] 给 {user_request.phone_number} 发送验证码:{code}")
return {"message": "验证码已发送"}
# TODO 后续升级为防止爆破测试手机号的
@users_router.post("/auth/varify_code", deprecated=True)
async def varify_code(data: VerifyCodeRequest, request: Request):
redis = request.app.state.redis
if not await service.varify_code(redis=redis, phone=data.phone, input_code=data.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
return {"message": "验证成功,可以重置密码"}
@users_router.post("/auth/forget-password/email", deprecated=False, description="邮箱遗忘接口")
async def email_forget_password(request: Request, user_request: UserResetEmailRequest):
"""
用户点击验证邮箱时启用
:param request:
:param user_request:
:return:
"""
user_email = user_request.email
user = await User.get_or_none(email=user_email)
if not user:
raise UserDoesNotExistsError("User does not exists")
redis = request.app.state.redis
code = service.generate_code()
await service.save_email_code(redis, email=user_request.email, code=code)
# 邮箱服务
await send_email_code(redis, user_request.email, code)
print(f"[DEBUG] 给 {user_request.email} 发送验证码:{code}")
return {"message": "验证码已发送"}
@users_router.post("/auth/varify_code/email")
async def email_varify_code(request: Request, data: VerifyEmailRequest):
redis = request.app.state.redis
reset_token = await service.verify_and_get_reset_token(redis=redis, email=data.email, input_code=data.code)
if not reset_token:
raise HTTPException(status_code=400, detail="验证码错误或已过期")
return {
"reset_token": reset_token,
}
@users_router.post("/auth/reset-password", deprecated=False)
async def reset_password(request: Request, reset_request: UserResetPasswordRequest):
# 校验密码是否合法
await service.validate_password(password=reset_request.password)
redis = request.app.state.redis
reset_token = request.headers.get("x-reset-token")
user_id = await service.is_reset_password(redis=redis, token=reset_token)
new_password = hash_password(raw_password=reset_request.password)
await User.filter(id=user_id).update(pwd_hashed=new_password)
return {"massage": "密码重置成功"}

155
app/api/user/service.py Normal file
View File

@ -0,0 +1,155 @@
import random
import re
import bcrypt
from fastapi import HTTPException
from jose import ExpiredSignatureError, JWTError
from redis.asyncio import Redis
from app.core.email_utils import send_email
from app.core.reset_utils import create_reset_token, save_reset_jti, verify_and_consume_reset_token, ResetTokenError
from app.models.base import ReservedWords, User
# 登陆校验
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="密码必须包含至少一个数字")
# 检查是否包含允许的特殊字符(白名单方式)
allowed_specials = r"!@#$%^&*()_\-+=\[\]{};:'\",.<>?/\\|`~"
if re.search(fr"[^\da-zA-Z{re.escape(allowed_specials)}]", password):
raise HTTPException(
status_code=400,
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
)
# 登陆校验
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")
# 生成随机验证码
def generate_code(length=6):
return "".join([str(random.randint(0, 9)) for _ in range(length)])
# PHONE MESSAGE
async def save_code_redis(redis: Redis, phone: str, code: str, expire: int = 300):
await redis.setex(f"sms:{phone}", expire, code)
async def varify_code(redis: Redis, phone: str, input_code: str):
stored = await redis.get(f"sms:{phone}")
return stored is not None and stored.decode() == input_code
# EMAIL
async def save_email_code(redis: Redis, email: str, code: str, expire: int = 300):
await redis.setex(f"email:{email}", expire, code)
async def send_email_code(redis: Redis, email: str, code: str):
await save_email_code(redis, email, code)
subject = "Lexiverse 用户邮箱验证码"
content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height:1.6;">
<h2 style="color:#4CAF50;">Lexiverse 验证码</h2>
<p>您好</p>
<p>您正在进行 <b>密码重置</b> 操作</p>
<p>
您的验证码是
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
</p>
<p>有效期 5 分钟请勿泄露给他人</p>
<hr>
<p style="font-size: 12px; color: #999;">
如果这不是您本人的操作请忽略此邮件
</p>
</body>
</html>
"""
send_email(email, subject, content)
async def __verify_email_code(redis: Redis, email: str, input_code: str) -> bool:
stored = await redis.getdel(f"email:{email}")
if stored is None or stored != input_code:
return False
return True
async def __get_reset_token(redis: Redis, email: str):
user = await User.get_or_none(email=email)
if user is None:
return None
reset_token, jti = create_reset_token(user_id=user.id, expire_seconds=300)
await save_reset_jti(redis, user.id, jti=jti, expire_seconds=300)
return reset_token
async def verify_and_get_reset_token(redis: Redis, email: str, input_code: str):
ok = await __verify_email_code(redis, email, input_code)
if not ok:
return None
return await __get_reset_token(redis, email)
async def is_reset_password(redis: Redis, token: str):
try:
user_id = await verify_and_consume_reset_token(redis=redis, token=token)
return user_id
except ResetTokenError as e:
print(e)
raise ResetTokenError("Token 非法或已过期")
except ExpiredSignatureError as e:
print(e)
except JWTError as e:
print(e)

View File

@ -1,12 +1,19 @@
from pydantic import BaseModel
from typing import Literal, Optional
from typing import Literal, Optional, Annotated
from fastapi import HTTPException
from pydantic import BaseModel, Field
default_portrait_url = '#'
ChinaPhone = Annotated[str, Field(pattern=r"^1[3-9]\d{9}$")]
EmailField = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
class UserIn(BaseModel):
username: str
password: str
email: Optional[EmailField] = None
phone: ChinaPhone
lang_pref: Literal['jp', 'fr', 'private'] = "private"
portrait: str = default_portrait_url
@ -57,3 +64,35 @@ class UpdateUserRequest(BaseModel):
class UserLoginRequest(BaseModel):
name: str
password: str
class UserSchema(BaseModel):
id: int
name: str
class UserResetPhoneRequest(BaseModel):
phone_number: ChinaPhone
class UserDoesNotExistsError(HTTPException):
def __init__(self, message: str):
super().__init__(status_code=404, detail=message)
class VerifyCodeRequest(BaseModel):
code: str
phone: ChinaPhone
class UserResetEmailRequest(BaseModel):
email: EmailField
class VerifyEmailRequest(BaseModel):
email: EmailField
code: str
class UserResetPasswordRequest(BaseModel):
password: str

69
app/core/email_utils.py Normal file
View File

@ -0,0 +1,69 @@
import os
import smtplib
from email.mime.text import MIMEText
from email.utils import formataddr
from dotenv import load_dotenv
load_dotenv()
SMTP_HOST = os.getenv("SMTP_HOST")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER")
SMTP_PASS = os.getenv("SMTP_PASS")
def send_email(to_email: str, subject: str, content: str):
msg = MIMEText(content, "html", "utf-8")
msg["From"] = formataddr(("noreply-Lexiverse", SMTP_USER))
msg["To"] = to_email
msg["Subject"] = subject
try:
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
code, response = server.login(SMTP_USER, SMTP_PASS)
print(f"[DEBUG] 登录响应: {code}, {response}")
result = server.sendmail(SMTP_USER, [to_email], msg.as_string())
print(f"[DEBUG] sendmail 返回: {result}")
print(f"[DEBUG] 邮件已发送到 {to_email}")
try:
server.quit() # 主动关闭连接
except smtplib.SMTPResponseException as e:
if e.smtp_code == -1:
# QQ 邮箱常见问题:断开时返回非标准响应
print(f"[WARN] 邮件发送成功,但服务器关闭连接时异常: {e.smtp_error}")
else:
raise
except smtplib.SMTPAuthenticationError:
print("[ERROR] 邮件认证失败,请检查账号和授权码是否正确")
raise
except Exception as e:
print(f"[ERROR] 邮件发送失败: {e}")
raise
def main(receiver: str, code: int = 123456):
content_model = content = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height:1.6;">
<h2 style="color:#4CAF50;">Lexiverse 验证码</h2>
<p>您好</p>
<p>您正在进行 <b>密码重置</b> 操作</p>
<p>
您的验证码是
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
</p>
<p>有效期 5 分钟请勿泄露给他人</p>
<hr>
<p style="font-size: 12px; color: #999;">
如果这不是您本人的操作请忽略此邮件
</p>
</body>
</html>
"""
send_email(to_email=receiver, subject='Test Email', content=content_model)
# send_email(to_email="GodricTan@gmail.com", subject="Test Email", content=content_model)
if __name__ == '__main__':
xza = "3480039769@qq.com"
bb = "1530799205@qq.com"
me = "GodricTan@gmail.com"
main(xza, code=123833)

0
app/core/phone_utils.py Normal file
View File

View File

@ -1,6 +1,7 @@
import redis.asyncio as redis
from typing import AsyncGenerator, Optional
import redis.asyncio as redis
# 全局 Redis 客户端
redis_client: Optional[redis.Redis] = None
@ -15,6 +16,8 @@ async def init_redis():
)
await redis_client.ping()
return redis_client
async def close_redis():
global redis_client
if redis_client:

69
app/core/reset_utils.py Normal file
View File

@ -0,0 +1,69 @@
import os
import uuid
from datetime import datetime, timezone, timedelta
from typing import Tuple
from dotenv import load_dotenv
from fastapi import HTTPException
from jose import jwt, ExpiredSignatureError, JWTError
from redis.asyncio import Redis
load_dotenv()
RESET_SECRET_KEY = os.getenv("RESET_SECRET_KEY")
ALGORITHM = 'HS256'
class ResetTokenError(HTTPException):
def __init__(self, message: str):
super().__init__(status_code=400, detail=message)
def create_reset_token(user_id: int, expire_seconds: int = 300) -> Tuple[str, str]:
"""生成 reset_token (JWT) 和 jti"""
jti = uuid.uuid4().hex
payload = {
'sub': str(user_id),
'purpose': 'reset_pw',
'exp': datetime.now(timezone.utc) + timedelta(hours=2),
'jti': jti,
}
token = jwt.encode(payload, RESET_SECRET_KEY, algorithm=ALGORITHM)
return token, jti
async def save_reset_jti(redis: Redis, user_id: int, jti: str, expire_seconds: int = 300):
"""把 jti 存到 Redis设置过期时间"""
await redis.setex(f"reset:{user_id}", expire_seconds, jti)
async def verify_and_consume_reset_token(redis: Redis, token: str) -> int | None:
"""
验证 reset_token
- 校验签名过期时间用途
- 校验 Redis jti 是否匹配
- 如果通过删除 Redis 记录确保一次性
- 返回 user_id否则 None
"""
try:
# 1. 解码并验证签名
payload = jwt.decode(token, RESET_SECRET_KEY, algorithms=[ALGORITHM], options={"verify_exp": False})
# 2. 校验用途
if payload.get("purpose") != "reset_pw":
return None
user_id = int(payload.get("sub"))
jti = payload.get("jti")
stored = await redis.getdel(f"reset:{user_id}")
if stored is None or stored != jti:
raise ResetTokenError("Token 非法或已过期")
return user_id
except ExpiredSignatureError as e:
raise ExpiredSignatureError(e)
except JWTError as e:
raise JWTError(e)

View File

@ -1,4 +1,5 @@
from . import signals
from .base import User
from .comments import CommentFr, CommentJp
from .fr import WordlistFr, DefinitionFr, AttachmentFr
from .jp import WordlistJp, DefinitionJp, AttachmentJp, PosType
from .base import User

View File

@ -17,15 +17,17 @@
"""
from tortoise.models import Model
from tortoise import fields
from tortoise.models import Model
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="用户头像")
portrait = fields.CharField(max_length=120, default='#', description="用户头像")
email = fields.CharField(max_length=120, null=True, description="e-mail")
encrypted_phone = fields.CharField(max_length=11, description="用户手机号")
language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
is_admin = fields.BooleanField(default=False, description="管理员权限")

View File

@ -0,0 +1,8 @@
"""
本脚本对于同一个加密密钥只允许生成一次
"""
import secrets
key = secrets.token_hex(32) # 生成 32字节 (256 bit) 十六进制字符串
print(key)
print(len(key)) # 64个字符 (32字节 -> hex编码 = 64字符)

View File

@ -0,0 +1,54 @@
import base64
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from dotenv import load_dotenv
load_dotenv()
class PhoneEncrypt:
def __init__(self, key: bytes):
self.key = key
if len(self.key) not in (16, 24, 32):
raise ValueError("AES 密钥必须是 16/24/32 字节")
@classmethod
def from_env(cls):
hex_key = os.getenv("AES_SECRET_KEY")
return cls(bytes.fromhex(hex_key))
def encrypt(self, phone: str) -> str:
"""
加密手机号 -> 返回 Base64 字符串
:return iv为解密初始数
ct为加密密文
"""
cipher = AES.new(self.key, AES.MODE_CBC) # 随机 IV
ct_bytes = cipher.encrypt(pad(phone.encode(), AES.block_size))
iv = base64.b64encode(cipher.iv).decode()
ct = base64.b64encode(ct_bytes).decode()
return f"{iv}:{ct}"
def decrypt(self, data: str) -> str:
"""解密 Base64 字符串 -> 返回手机号"""
iv_b64, ct_b64 = data.split(":")
iv = base64.b64decode(iv_b64)
ct = base64.b64decode(ct_b64)
cipher = AES.new(self.key, AES.MODE_CBC, iv)
pt = unpad(cipher.decrypt(ct), AES.block_size)
return pt.decode()
def main():
phone_encrypt = PhoneEncrypt()
encrypted = phone_encrypt.encrypt("13568847988")
print(encrypted)
decrypted = phone_encrypt.decrypt(encrypted)
print(decrypted)
if __name__ == '__main__':
main()

View File

@ -1,84 +1,17 @@
import re
import bcrypt
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import Tuple, Dict, Annotated
import redis.asyncio as redis
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 app.models.base import User
from settings import settings
redis_client = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)
ALGORITHM = "HS256"
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="密码必须包含至少一个数字")
# 检查是否包含允许的特殊字符(白名单方式)
allowed_specials = r"!@#$%^&*()_\-+=\[\]{};:'\",.<>?/\\|`~"
if re.search(fr"[^\da-zA-Z{re.escape(allowed_specials)}]", password):
raise HTTPException(
status_code=400,
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
)
# 登陆校验
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 _extract_bearer_token(request: Request) -> str:
"""
小工具提取 Bearer Token兼容大小写/多空格

146
docs/api.md Normal file
View File

@ -0,0 +1,146 @@
# FastAPI
版本0.1.0
**认证方式**
部分接口需要 `OAuth2PasswordBearer`,在 Header 中添加:
```http
Authorization: Bearer <token>
```
------
## User API
### Register
**Method**: `POST`
**Path**: `/users/register`
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
|----------|--------|------|------|
| username | string | 是 | Username |
| password | string | 是 | Password |
| lang_pref | string(enum: jp, fr, private默认: private) | 否 | Lang Pref |
| portrait | string(默认: #) | 否 | Portrait |
#### 响应
**200 成功**
| 字段 | 类型 | 必填 | 说明 |
|--------|--------|------|------|
| name | string | 是 | Name |
| potrait| string(默认: #) | 否 | Potrait |
**422 验证错误**
返回 `HTTPValidationError`
---
### User Modification
**Method**: `PUT`
**Path**: `/users/update`
需要认证
描述:根据 JSON 内容修改对应字段。
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
|------------------|--------|------|------|
| current_password | string / null | 否 | Current Password |
| new_username | string / null | 否 | New Username |
| new_password | string / null | 否 | New Password |
| new_language | string(enum: jp, fr, private默认: private) | 否 | New Language |
#### 响应
- **200 成功**(空 schema
- **422 验证错误**
------
### User Logout
**Method**: `POST`
**Path**: `/users/logout`
需要认证
#### 响应
- **200 成功**(空 schema
------
## Dictionary Search API
### Search
**Method**: `POST`
**Path**: `/search`
需要认证
#### 请求体
| 字段 | 类型 | 必填 | 说明 |
| -------- | ----------------------------------------- | ---- | -------- |
| query | string | 是 | Query |
| language | string(enum: fr, jp) | 是 | Language |
| sort | string(enum: relevance, date默认: date) | 否 | Sort |
| order | string(enum: asc, des默认: des) | 否 | Order |
#### 响应
**200 成功**
| 字段 | 类型 | 必填 | 说明 |
| -------- | --------------------------------------- | ---- | -------- |
| query | string | 是 | Query |
| pos | array | 是 | Pos |
| contents | array(SearchItemFr[] 或 SearchItemJp[]) | 是 | Contents |
**422 验证错误**
### Search List
**Method**: `POST`
**Path**: `search/list`
需要认证
描述:检索提示接口,根据用户输入返回候选列表。
#### 请求体
`/search`
#### 响应
- **200 成功**(空 schema
- **422 验证错误**
------
## Redis Test-Only API
### Ping Redis
**Method**: `GET`
**Path**: `/ping-redis`
#### 响应
- **200 成功**
## 错误模型
### ValidationError
| 字段 | 类型 | 必填 | 说明 |
| ---- | ----------------------- | ---- | ---------- |
| loc | array[string / integer] | 是 | Location |
| msg | string | 是 | Message |
| type | string | 是 | Error Type |
### HTTPValidationError
| 字段 | 类型 | 必填 | 说明 |
| ------ | ---------------------- | ---- | ------ |
| detail | array[ValidationError] | 否 | Detail |

24
main.py
View File

@ -1,25 +1,27 @@
from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from tortoise.contrib.fastapi import register_tortoise
from app.api.redis_test import redis_test_router
from app.api.translator import translator_router
from app.utils import redis_client
from settings import TORTOISE_ORM, ONLINE_SETTINGS
from app.api.users import users_router
from app.api.admin.router import admin_router
from app.api.search import dict_search
from app.core.redis import init_redis, close_redis
import app.models.signals
from app.api.admin.router import admin_router
from app.api.redis_test import redis_test_router
from app.api.search import dict_search
from app.api.translator import translator_router
from app.api.user.routes import users_router
from app.core.redis import init_redis, close_redis
from app.utils.phone_encrypt import PhoneEncrypt
from settings import ONLINE_SETTINGS
@asynccontextmanager
async def lifespan(app: FastAPI):
# ---- startup ----
await init_redis()
app.state.redis = await init_redis()
# phone_encrypt
app.state.phone_encrypto = PhoneEncrypt.from_env() # 接口中通过 Request 访问
try:
yield
finally:
@ -28,8 +30,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(lifespan=lifespan)
import debug.httpdebugger
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,

View File

@ -11,6 +11,7 @@ TORTOISE_ORM = {
'app.models.base',
'app.models.fr',
'app.models.jp',
'app.models.comments',
'aerich.models' # aerich自带模型类必须填入
],
'default_connection': 'default',
@ -31,6 +32,7 @@ ONLINE_SETTINGS = {
'app.models.base',
'app.models.fr',
'app.models.jp',
'app.models.comments'
'aerich.models' # aerich自带模型类必须填入
],
'default_connection': 'default',