diff --git a/.idea/dict_server.iml b/.idea/dict_server.iml
index 53b48ab..5305fe2 100644
--- a/.idea/dict_server.iml
+++ b/.idea/dict_server.iml
@@ -4,7 +4,7 @@
-
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
index 105ce2d..dd4c951 100644
--- a/.idea/inspectionProfiles/profiles_settings.xml
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -1,5 +1,6 @@
+
diff --git a/.idea/misc.xml b/.idea/misc.xml
index 1b75482..dbda99f 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,5 +3,5 @@
-
+
\ No newline at end of file
diff --git a/app/api/search.py b/app/api/search.py
index 548a8a4..d1c82eb 100644
--- a/app/api/search.py
+++ b/app/api/search.py
@@ -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)):
"""
@@ -39,7 +77,7 @@ async def search(request: Request, body: SearchRequest, user=Depends(get_current
# 修改freq
first_word = word_contents[0].word
current_freq = first_word.freq
- await first_word.update(freq=current_freq+1)
+ await first_word.update(freq=current_freq + 1)
pos_seen = set()
pos_contents = []
diff --git a/app/api/user/__init__.py b/app/api/user/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/api/users.py b/app/api/user/routes.py
similarity index 50%
rename from app/api/users.py
rename to app/api/user/routes.py
index 8d26974..83f6d1d 100644
--- a/app/api/users.py
+++ b/app/api/user/routes.py
@@ -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)
@@ -28,7 +31,7 @@ async def register(user_in: UserIn):
defaults={
"pwd_hashed": hashed_pwd,
"language": lang_pref,
- "portrait": user_in.portrait,},
+ "portrait": user_in.portrait, },
)
if not created:
raise HTTPException(status_code=400, detail="Username already exists")
@@ -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": "密码重置成功"}
+
diff --git a/app/api/user/service.py b/app/api/user/service.py
new file mode 100644
index 0000000..01c5c0d
--- /dev/null
+++ b/app/api/user/service.py
@@ -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"""
+
+
+ Lexiverse 验证码
+ 您好,
+ 您正在进行 密码重置 操作。
+
+ 您的验证码是:
+ {code}
+
+ 有效期 5 分钟,请勿泄露给他人。
+
+
+ 如果这不是您本人的操作,请忽略此邮件。
+
+
+
+ """
+
+ 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)
diff --git a/app/schemas/user_schemas.py b/app/api/user/user_schemas.py
similarity index 67%
rename from app/schemas/user_schemas.py
rename to app/api/user/user_schemas.py
index 6d4e070..61037a4 100644
--- a/app/schemas/user_schemas.py
+++ b/app/api/user/user_schemas.py
@@ -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
diff --git a/app/core/email_utils.py b/app/core/email_utils.py
new file mode 100644
index 0000000..ad60a04
--- /dev/null
+++ b/app/core/email_utils.py
@@ -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"""
+
+
+ Lexiverse 验证码
+ 您好,
+ 您正在进行 密码重置 操作。
+
+ 您的验证码是:
+ {code}
+
+ 有效期 5 分钟,请勿泄露给他人。
+
+
+ 如果这不是您本人的操作,请忽略此邮件。
+
+
+
+ """
+ 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)
diff --git a/app/core/phone_utils.py b/app/core/phone_utils.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/core/redis.py b/app/core/redis.py
index 6fa01ee..a5424e1 100644
--- a/app/core/redis.py
+++ b/app/core/redis.py
@@ -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:
diff --git a/app/core/reset_utils.py b/app/core/reset_utils.py
new file mode 100644
index 0000000..c8643cf
--- /dev/null
+++ b/app/core/reset_utils.py
@@ -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)
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 5fa0459..e901846 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -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
\ No newline at end of file
diff --git a/app/models/base.py b/app/models/base.py
index 86fc52e..cc2a499 100644
--- a/app/models/base.py
+++ b/app/models/base.py
@@ -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="管理员权限")
diff --git a/app/utils/encrypt_key_generator.py b/app/utils/encrypt_key_generator.py
new file mode 100644
index 0000000..9623054
--- /dev/null
+++ b/app/utils/encrypt_key_generator.py
@@ -0,0 +1,8 @@
+"""
+本脚本对于同一个加密密钥只允许生成一次
+"""
+import secrets
+
+key = secrets.token_hex(32) # 生成 32字节 (256 bit) 十六进制字符串
+print(key)
+print(len(key)) # 64个字符 (32字节 -> hex编码 = 64字符)
diff --git a/app/utils/phone_encrypt.py b/app/utils/phone_encrypt.py
new file mode 100644
index 0000000..c467872
--- /dev/null
+++ b/app/utils/phone_encrypt.py
@@ -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()
diff --git a/app/utils/security.py b/app/utils/security.py
index 0c2508f..bf9b811 100644
--- a/app/utils/security.py
+++ b/app/utils/security.py
@@ -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(兼容大小写/多空格)
@@ -140,7 +73,7 @@ async def get_current_user(
async def is_admin_user(
- user_payload: Tuple[User, Dict] = Depends(get_current_user),
+ user_payload: Tuple[User, Dict] = Depends(get_current_user),
) -> Tuple[User, Dict]:
user, payload = user_payload
if not getattr(user, "is_admin", False):
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..601427f
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,146 @@
+# FastAPI
+版本:0.1.0
+
+**认证方式**
+部分接口需要 `OAuth2PasswordBearer`,在 Header 中添加:
+
+```http
+Authorization: Bearer
+```
+
+------
+
+## 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 |
+
diff --git a/main.py b/main.py
index bb785cb..5b14577 100644
--- a/main.py
+++ b/main.py
@@ -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,
diff --git a/settings.py b/settings.py
index 37fd448..f5d0319 100644
--- a/settings.py
+++ b/settings.py
@@ -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',