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',