用户接口相关邮箱验证功能接口

This commit is contained in:
Miyamizu-MitsuhaSang 2025-10-04 14:59:34 +08:00
parent fc053c239d
commit 340b2a7b6b
12 changed files with 785 additions and 435 deletions

View File

@ -4,6 +4,7 @@
<file url="file://$PROJECT_DIR$/migrations/models/11_20250803092810_update.py" dialect="MySQL" /> <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" /> <file url="file://$PROJECT_DIR$/migrations/models/16_20250806104900_update.py" dialect="GenericSQL" />
<file url="file://$PROJECT_DIR$/migrations/models/21_20250827100315_update.py" dialect="MySQL" /> <file url="file://$PROJECT_DIR$/migrations/models/21_20250827100315_update.py" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/migrations/models/33_20250929111212_update.py" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/scripts/update_fr.py" dialect="MySQL" /> <file url="file://$PROJECT_DIR$/scripts/update_fr.py" dialect="MySQL" />
</component> </component>
</project> </project>

103
README.md
View File

@ -24,6 +24,7 @@ Authorization: Bearer <your_jwt_token>
### 1. 用户认证模块 (`/users`) ### 1. 用户认证模块 (`/users`)
#### 1.1 用户注册 #### 1.1 用户注册
##### 1.1.1 注册主接口
- **接口**: `POST /users/register` - **接口**: `POST /users/register`
- **描述**: 新用户注册 - **描述**: 新用户注册
@ -33,8 +34,11 @@ Authorization: Bearer <your_jwt_token>
{ {
"username": "string", "username": "string",
"password": "string", "password": "string",
"email": "EmailFields[string]",
"phone": "PhoneFields",
"lang_pref": "jp" | "fr" | "private", "lang_pref": "jp" | "fr" | "private",
"portrait": "string" "portrait": "string",
"code": "string"
} }
``` ```
@ -51,6 +55,30 @@ Authorization: Bearer <your_jwt_token>
- `200`: 注册成功 - `200`: 注册成功
- `400`: 参数验证失败 - `400`: 参数验证失败
##### 1.1.2 邮箱验证
- **接口**: `POST /users/register/email_verify`
- **描述**: 新用户注册时的邮箱验证
- **请求体**:
```json
{
"user_email" : "string"
}
```
- **响应**:
```json
{
"message": "验证码已发送"
}
```
- **状态码**:
- `200`: 邮件发送成功
- `400`: 邮箱已被使用
#### 1.2 用户登录 #### 1.2 用户登录
- **接口**: `POST /users/login` - **接口**: `POST /users/login`
@ -120,6 +148,79 @@ Authorization: Bearer <your_jwt_token>
- `200`: 更新成功 - `200`: 更新成功
- `400`: 原密码错误或用户名为保留词 - `400`: 原密码错误或用户名为保留词
#### 1.5 邮箱找回密码(发送验证码)
- **接口**: `POST /users/auth/forget-password/email`
- **描述**: 用户请求通过邮箱找回密码时,向注册邮箱发送验证码
- **请求体**:
```json
{
"email": "string"
}
```
- **响应**:
```json
{
"message": "验证码已发送"
}
```
- **状态码**
- `200`: 更新成功
- `404`: 用户不存在
#### 1.6 邮箱验证码验证
- **接口**: `POST /users/auth/varify_code/email`
- **描述**: 用户输入邮箱验证码后,验证验证码是否有效,返回重置令牌
- **请求体**:
```json
{
"email": "string",
"code": "string"
}
```
- **响应**
```json
{
"reset_token": "string"
}
```
- **状态码**
- `200`: 验证码验证成功
- `400`: 验证码错误或已过期
#### 1.7 重置密码
- **接口**: `POST /users/auth/reset-password`
- **描述**: 用户通过邮箱验证码获得的重置令牌来设置新密码
- **请求头**:
- `x-reset-token`: 重置令牌
- **请求体**:
```json
{
"password": "string"
}
```
- **响应**
```json
{
"massage": "密码重置成功"
}
```
- **状态码**
- `200`: 密码重置成功
- `400`: 密码不合法或令牌无效
--- ---
### 2. 词典搜索模块 ### 2. 词典搜索模块

View File

@ -1,9 +1,13 @@
import redis import redis
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.core.redis import redis_client, get_redis
from app.core.redis import get_redis
redis_test_router = APIRouter() redis_test_router = APIRouter()
@redis_test_router.get("/ping-redis") @redis_test_router.get("/ping-redis")
async def ping_redis(r: redis.Redis = Depends(get_redis)): async def ping_redis(r: redis.Redis = Depends(get_redis)):
return {"pong": await r.ping()} return {
"pong": await r.ping(),
"redis": r.connection_pool.connection_kwargs,
}

View File

@ -6,41 +6,70 @@ from fastapi import APIRouter, HTTPException, Depends, Request
from jose import jwt from jose import jwt
from app.api.user.user_schemas import UserIn, UpdateUserRequest, UserLoginRequest, UserResetPhoneRequest, \ from app.api.user.user_schemas import UserIn, UpdateUserRequest, UserLoginRequest, UserResetPhoneRequest, \
UserDoesNotExistsError, VerifyCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest VerifyPhoneCodeRequest, UserResetEmailRequest, UserResetPasswordRequest, VerifyEmailRequest
from app.core.redis import get_redis from app.core.redis import get_redis
from app.models.base import ReservedWords, User, Language from app.models.base import ReservedWords, User, Language
from app.utils.security import get_current_user from app.utils.security import get_current_user
from settings import settings from settings import settings
from . import service from . import service
from .service import hash_password, send_email_code
users_router = APIRouter() users_router = APIRouter()
@users_router.post("/register") @users_router.post("/register")
async def register(user_in: UserIn): async def register(req: Request, user_in: UserIn):
await service.validate_username(user_in.username) await service.validate_username(user_in.username)
await service.validate_password(user_in.password) await service.validate_password(user_in.password)
# await service.validate_email_exists(user_in.email)
result = await service.verify_email_code(
redis=req.app.state.redis,
email=user_in.email,
input_code=user_in.code
)
if not result:
raise HTTPException(status_code=400, detail="验证码错误或已过期")
hashed_pwd = service.hash_password(user_in.password) hashed_pwd = service.hash_password(user_in.password)
lang_pref = await Language.get(code=user_in.lang_pref) lang_pref = await Language.get(code=user_in.lang_pref)
new_user, created = await User.get_or_create( encrypted_phone = (
req.app.state.phone_encrypto.encrypt(user_in.phone)) \
if user_in.phone else None
new_user = await User.create(
name=user_in.username, name=user_in.username,
defaults={ email=user_in.email,
"pwd_hashed": hashed_pwd, pwd_hashed=hashed_pwd,
"language": lang_pref, language=lang_pref,
"portrait": user_in.portrait, }, encrypted_phone=encrypted_phone,
) )
if not created:
raise HTTPException(status_code=400, detail="Username already exists")
return { return {
"id": new_user.id, "id": new_user.id,
"message": "register success", "message": "register success",
} }
@users_router.post("/register/email_verify")
async def register_email_verify(req: Request, user_email: UserResetEmailRequest):
await service.validate_email_exists(user_email.email)
code = service.generate_code()
redis = req.app.state.redis
await service.save_email_code(redis, email=user_email.email, code=code)
await service.send_email_code(
redis=redis,
email=user_email.email,
code=code,
ops_type="reg"
)
print(f"[DEBUG] 给 {user_email.email} 发送验证码:{code}")
return {"message": "验证码已发送"}
@users_router.put("/update", deprecated=False) @users_router.put("/update", deprecated=False)
async def user_modification(updated_user: UpdateUserRequest, current_user: User = Depends(get_current_user)): async def user_modification(updated_user: UpdateUserRequest, current_user: User = Depends(get_current_user)):
""" """
@ -62,7 +91,7 @@ async def user_modification(updated_user: UpdateUserRequest, current_user: User
# 修改密码(如果提供) # 修改密码(如果提供)
if updated_user.new_password: if updated_user.new_password:
current_user.password_hash = hash_password(updated_user.new_password) current_user.password_hash = service.hash_password(updated_user.new_password)
@users_router.post("/login") @users_router.post("/login")
@ -110,20 +139,11 @@ async def user_logout(request: Request,
now = datetime.now(timezone.utc).timestamp() now = datetime.now(timezone.utc).timestamp()
ttl = max(int(exp - now), 1) if exp else 7200 ttl = max(int(exp - now), 1) 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") await redis_client.setex(f"blacklist:{raw_token}", ttl, "true")
return {"message": "logout ok"} return {"message": "logout ok"}
# 后续通过参数合并 # 后续通过参数合并
@users_router.post("/auth/forget-password/phone", deprecated=True) @users_router.post("/auth/forget-password/phone", deprecated=True)
async def forget_password(request: Request, user_request: UserResetPhoneRequest): async def forget_password(request: Request, user_request: UserResetPhoneRequest):
@ -131,7 +151,7 @@ async def forget_password(request: Request, user_request: UserResetPhoneRequest)
user = await User.get_or_none(encrypted_phone=encrypted_phone) user = await User.get_or_none(encrypted_phone=encrypted_phone)
if not user: if not user:
raise UserDoesNotExistsError() raise HTTPException(status_code=404, detail="User does not exists")
redis = request.app.state.Redis redis = request.app.state.Redis
code = service.generate_code() code = service.generate_code()
@ -147,7 +167,7 @@ async def forget_password(request: Request, user_request: UserResetPhoneRequest)
# TODO 后续升级为防止爆破测试手机号的 # TODO 后续升级为防止爆破测试手机号的
@users_router.post("/auth/varify_code", deprecated=True) @users_router.post("/auth/varify_code", deprecated=True)
async def varify_code(data: VerifyCodeRequest, request: Request): async def varify_code(data: VerifyPhoneCodeRequest, request: Request):
redis = request.app.state.redis redis = request.app.state.redis
if not await service.varify_code(redis=redis, phone=data.phone, input_code=data.code): if not await service.varify_code(redis=redis, phone=data.phone, input_code=data.code):
raise HTTPException(status_code=400, detail="验证码错误或已过期") raise HTTPException(status_code=400, detail="验证码错误或已过期")
@ -165,14 +185,14 @@ async def email_forget_password(request: Request, user_request: UserResetEmailRe
user_email = user_request.email user_email = user_request.email
user = await User.get_or_none(email=user_email) user = await User.get_or_none(email=user_email)
if not user: if not user:
raise UserDoesNotExistsError("User does not exists") raise HTTPException(status_code=404, detail="User does not exists")
redis = request.app.state.redis redis = request.app.state.redis
code = service.generate_code() code = service.generate_code()
await service.save_email_code(redis, email=user_request.email, code=code) # await service.save_email_code(redis, email=user_request.email, code=code)
# 邮箱服务 # 邮箱服务
await send_email_code(redis, user_request.email, code) await service.send_email_code(redis, user_request.email, code, ops_type="reset")
print(f"[DEBUG] 给 {user_request.email} 发送验证码:{code}") print(f"[DEBUG] 给 {user_request.email} 发送验证码:{code}")
@ -190,6 +210,7 @@ async def email_varify_code(request: Request, data: VerifyEmailRequest):
"reset_token": reset_token, "reset_token": reset_token,
} }
@users_router.post("/auth/reset-password", deprecated=False) @users_router.post("/auth/reset-password", deprecated=False)
async def reset_password(request: Request, reset_request: UserResetPasswordRequest): async def reset_password(request: Request, reset_request: UserResetPasswordRequest):
# 校验密码是否合法 # 校验密码是否合法
@ -199,9 +220,8 @@ async def reset_password(request: Request, reset_request: UserResetPasswordReque
reset_token = request.headers.get("x-reset-token") reset_token = request.headers.get("x-reset-token")
user_id = await service.is_reset_password(redis=redis, token=reset_token) user_id = await service.is_reset_password(redis=redis, token=reset_token)
new_password = hash_password(raw_password=reset_request.password) new_password = service.hash_password(raw_password=reset_request.password)
await User.filter(id=user_id).update(pwd_hashed=new_password) await User.filter(id=user_id).update(pwd_hashed=new_password)
return {"massage": "密码重置成功"} return {"massage": "密码重置成功"}

View File

@ -1,5 +1,6 @@
import random import random
import re import re
from typing import Literal
import bcrypt import bcrypt
from fastapi import HTTPException from fastapi import HTTPException
@ -38,6 +39,11 @@ async def validate_password(password: str):
detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}" detail=f"密码只能包含字母、数字和常见特殊字符 {allowed_specials}"
) )
async def validate_email_exists(email: str):
user = await User.get_or_none(email=email)
if user:
raise HTTPException(status_code=400, detail="邮箱已经被使用,请更换其他邮箱后重试")
# 登陆校验 # 登陆校验
async def verify_password(raw_password: str, hashed_password: str) -> bool: async def verify_password(raw_password: str, hashed_password: str) -> bool:
@ -79,7 +85,7 @@ async def save_code_redis(redis: Redis, phone: str, code: str, expire: int = 300
await redis.setex(f"sms:{phone}", expire, code) await redis.setex(f"sms:{phone}", expire, code)
async def varify_code(redis: Redis, phone: str, input_code: str): async def varify_phone_code(redis: Redis, phone: str, input_code: str):
stored = await redis.get(f"sms:{phone}") stored = await redis.get(f"sms:{phone}")
return stored is not None and stored.decode() == input_code return stored is not None and stored.decode() == input_code
@ -89,16 +95,20 @@ async def save_email_code(redis: Redis, email: str, code: str, expire: int = 300
await redis.setex(f"email:{email}", expire, code) await redis.setex(f"email:{email}", expire, code)
async def send_email_code(redis: Redis, email: str, code: str): async def send_email_code(redis: Redis, email: str, code: str, ops_type: Literal["reg", "reset"]):
await save_email_code(redis, email, code) await save_email_code(redis, email, code)
ops_dict = {
"reg" : "用户注册",
"reset" : "密码重置",
}
subject = "Lexiverse 用户邮箱验证码" subject = "Lexiverse 用户邮箱验证码"
content = f""" content = f"""
<html> <html>
<body style="font-family: Arial, sans-serif; line-height:1.6;"> <body style="font-family: Arial, sans-serif; line-height:1.6;">
<h2 style="color:#4CAF50;">Lexiverse 验证码</h2> <h2 style="color:#4CAF50;">Lexiverse 验证码</h2>
<p>您好</p> <p>您好</p>
<p>您正在进行 <b>密码重置</b> 操作</p> <p>您正在进行 <b>{ops_dict[ops_type]}</b> 操作</p>
<p> <p>
您的验证码是 您的验证码是
<span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span> <span style="font-size: 24px; font-weight: bold; color: #d9534f;">{code}</span>
@ -115,7 +125,7 @@ async def send_email_code(redis: Redis, email: str, code: str):
send_email(email, subject, content) send_email(email, subject, content)
async def __verify_email_code(redis: Redis, email: str, input_code: str) -> bool: async def verify_email_code(redis: Redis, email: str, input_code: str) -> bool:
stored = await redis.getdel(f"email:{email}") stored = await redis.getdel(f"email:{email}")
if stored is None or stored != input_code: if stored is None or stored != input_code:
return False return False
@ -135,7 +145,7 @@ async def __get_reset_token(redis: Redis, email: str):
async def verify_and_get_reset_token(redis: Redis, email: str, input_code: str): async def verify_and_get_reset_token(redis: Redis, email: str, input_code: str):
ok = await __verify_email_code(redis, email, input_code) ok = await verify_email_code(redis, email, input_code)
if not ok: if not ok:
return None return None

View File

@ -1,7 +1,8 @@
import re
from typing import Literal, Optional, Annotated from typing import Literal, Optional, Annotated
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator
default_portrait_url = '#' default_portrait_url = '#'
@ -12,11 +13,26 @@ EmailField = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
class UserIn(BaseModel): class UserIn(BaseModel):
username: str username: str
password: str password: str
email: Optional[EmailField] = None email: str
phone: ChinaPhone phone: Optional[str] = None
lang_pref: Literal['jp', 'fr', 'private'] = "private" lang_pref: Literal['jp', 'fr', 'private'] = "private"
portrait: str = default_portrait_url portrait: str = default_portrait_url
code: str
@field_validator('email')
@classmethod
def validate_email(cls, v):
if not re.match(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$", string=v):
raise HTTPException(status_code=400, detail="邮箱格式错误")
return v
@field_validator('phone')
@classmethod
def validate_phone(cls, v):
if not re.match(pattern=r"^1[3-9]\d{9}$", string=v):
raise HTTPException(status_code=400, detail="手机号格式错误")
return v
# @field_validator('username') # @field_validator('username')
# @classmethod # @classmethod
# def validate_username(cls, v): # def validate_username(cls, v):
@ -75,12 +91,7 @@ class UserResetPhoneRequest(BaseModel):
phone_number: ChinaPhone phone_number: ChinaPhone
class UserDoesNotExistsError(HTTPException): class VerifyPhoneCodeRequest(BaseModel):
def __init__(self, message: str):
super().__init__(status_code=404, detail=message)
class VerifyCodeRequest(BaseModel):
code: str code: str
phone: ChinaPhone phone: ChinaPhone

View File

@ -9,7 +9,7 @@ redis_client: Optional[redis.Redis] = None
async def init_redis(): async def init_redis():
global redis_client global redis_client
if redis_client is None: if redis_client is None:
redis_client = await redis.Redis( redis_client = redis.Redis(
host="127.0.0.1", host="127.0.0.1",
port=6379, port=6379,
decode_responses=True, # 返回 str 而不是 Bytes decode_responses=True, # 返回 str 而不是 Bytes

View File

@ -26,8 +26,8 @@ class User(Model):
name = fields.CharField(max_length=20, unique=True, description="用户名") name = fields.CharField(max_length=20, unique=True, description="用户名")
pwd_hashed = fields.CharField(max_length=60, description="密码") pwd_hashed = fields.CharField(max_length=60, description="密码")
portrait = fields.CharField(max_length=120, default='#', description="用户头像") portrait = fields.CharField(max_length=120, default='#', description="用户头像")
email = fields.CharField(max_length=120, null=True, description="e-mail") email = fields.CharField(max_length=120, description="e-mail")
encrypted_phone = fields.CharField(max_length=11, description="用户手机号") encrypted_phone = fields.CharField(max_length=11, description="用户手机号", null=True)
language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE) language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
is_admin = fields.BooleanField(default=False, description="管理员权限") is_admin = fields.BooleanField(default=False, description="管理员权限")

View File

@ -13,7 +13,7 @@ from app.api.translator import translator_router
from app.api.user.routes import users_router from app.api.user.routes import users_router
from app.core.redis import init_redis, close_redis from app.core.redis import init_redis, close_redis
from app.utils.phone_encrypt import PhoneEncrypt from app.utils.phone_encrypt import PhoneEncrypt
from settings import ONLINE_SETTINGS from settings import TORTOISE_ORM
@asynccontextmanager @asynccontextmanager
@ -41,7 +41,7 @@ app.add_middleware(
register_tortoise( register_tortoise(
app=app, app=app,
config=ONLINE_SETTINGS, config=TORTOISE_ORM,
) )
app.include_router(users_router, tags=["User API"], prefix="/users") app.include_router(users_router, tags=["User API"], prefix="/users")

View File

@ -9,10 +9,13 @@ dependencies = [
"aiosqlite==0.21.0", "aiosqlite==0.21.0",
"annotated-types==0.7.0", "annotated-types==0.7.0",
"anyio==4.10.0", "anyio==4.10.0",
"async-timeout==5.0.1",
"asyncclick==8.2.2.2", "asyncclick==8.2.2.2",
"asyncmy==0.2.10", "asyncmy==0.2.10",
"bcrypt==4.3.0", "bcrypt==4.3.0",
"certifi==2025.8.3",
"cffi==1.17.1", "cffi==1.17.1",
"charset-normalizer==3.4.3",
"click==8.2.1", "click==8.2.1",
"colorama==0.4.6", "colorama==0.4.6",
"cryptography==45.0.6", "cryptography==45.0.6",
@ -23,22 +26,28 @@ dependencies = [
"fastapi==0.116.1", "fastapi==0.116.1",
"fugashi==1.5.1", "fugashi==1.5.1",
"h11==0.16.0", "h11==0.16.0",
"httpcore==1.0.9",
"httpx==0.28.1",
"idna==3.10", "idna==3.10",
"iso8601==2.1.0", "iso8601==2.1.0",
"jaconv==0.4.0", "jaconv==0.4.0",
"numpy==2.3.2", "numpy==2.3.2",
"openpyxl==3.1.5", "openpyxl==3.1.5",
"pandas==2.3.1", "pandas==2.3.1",
"pandas-stubs==2.3.2.250827",
"pyasn1==0.6.1", "pyasn1==0.6.1",
"pycparser==2.22", "pycparser==2.22",
"pycryptodome==3.23.0",
"pydantic==2.11.7", "pydantic==2.11.7",
"pydantic-core==2.33.2", "pydantic-core==2.33.2",
"pykakasi==2.3.0", "pykakasi==2.3.0",
"pypika-tortoise==0.6.1", "pypika-tortoise==0.6.1",
"python-dateutil==2.9.0.post0", "python-dateutil==2.9.0.post0",
"python-dotenv==1.1.1",
"python-jose==3.5.0", "python-jose==3.5.0",
"pytz==2025.2", "pytz==2025.2",
"redis==6.4.0", "redis==6.4.0",
"requests==2.32.5",
"rsa==4.9.1", "rsa==4.9.1",
"six==1.17.0", "six==1.17.0",
"sniffio==1.3.1", "sniffio==1.3.1",
@ -47,7 +56,8 @@ dependencies = [
"typing-extensions==4.14.1", "typing-extensions==4.14.1",
"typing-inspection==0.4.1", "typing-inspection==0.4.1",
"tzdata==2025.2", "tzdata==2025.2",
"unidic-lite>=1.0.8", "unidic-lite==1.0.8",
"urllib3==2.5.0",
"uvicorn==0.35.0", "uvicorn==0.35.0",
"wrapt==1.17.3", "wrapt==1.17.3",
] ]

View File

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

955
uv.lock

File diff suppressed because it is too large Load Diff