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

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/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/33_20250929111212_update.py" dialect="MySQL" />
<file url="file://$PROJECT_DIR$/scripts/update_fr.py" dialect="MySQL" />
</component>
</project>

103
README.md
View File

@ -24,6 +24,7 @@ Authorization: Bearer <your_jwt_token>
### 1. 用户认证模块 (`/users`)
#### 1.1 用户注册
##### 1.1.1 注册主接口
- **接口**: `POST /users/register`
- **描述**: 新用户注册
@ -33,8 +34,11 @@ Authorization: Bearer <your_jwt_token>
{
"username": "string",
"password": "string",
"email": "EmailFields[string]",
"phone": "PhoneFields",
"lang_pref": "jp" | "fr" | "private",
"portrait": "string"
"portrait": "string",
"code": "string"
}
```
@ -51,6 +55,30 @@ Authorization: Bearer <your_jwt_token>
- `200`: 注册成功
- `400`: 参数验证失败
##### 1.1.2 邮箱验证
- **接口**: `POST /users/register/email_verify`
- **描述**: 新用户注册时的邮箱验证
- **请求体**:
```json
{
"user_email" : "string"
}
```
- **响应**:
```json
{
"message": "验证码已发送"
}
```
- **状态码**:
- `200`: 邮件发送成功
- `400`: 邮箱已被使用
#### 1.2 用户登录
- **接口**: `POST /users/login`
@ -120,6 +148,79 @@ Authorization: Bearer <your_jwt_token>
- `200`: 更新成功
- `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. 词典搜索模块

View File

@ -1,9 +1,13 @@
import redis
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.get("/ping-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 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.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):
async def register(req: Request, user_in: UserIn):
await service.validate_username(user_in.username)
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)
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,
defaults={
"pwd_hashed": hashed_pwd,
"language": lang_pref,
"portrait": user_in.portrait, },
email=user_in.email,
pwd_hashed=hashed_pwd,
language=lang_pref,
encrypted_phone=encrypted_phone,
)
if not created:
raise HTTPException(status_code=400, detail="Username already exists")
return {
"id": new_user.id,
"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)
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:
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")
@ -110,28 +139,19 @@ async def user_logout(request: Request,
now = datetime.now(timezone.utc).timestamp()
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")
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()
raise HTTPException(status_code=404, detail="User does not exists")
redis = request.app.state.Redis
code = service.generate_code()
@ -147,7 +167,7 @@ async def forget_password(request: Request, user_request: UserResetPhoneRequest)
# TODO 后续升级为防止爆破测试手机号的
@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
if not await service.varify_code(redis=redis, phone=data.phone, input_code=data.code):
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 = await User.get_or_none(email=user_email)
if not user:
raise UserDoesNotExistsError("User does not exists")
raise HTTPException(status_code=404, detail="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 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}")
@ -187,9 +207,10 @@ async def email_varify_code(request: Request, data: VerifyEmailRequest):
raise HTTPException(status_code=400, detail="验证码错误或已过期")
return {
"reset_token": reset_token,
"reset_token": reset_token,
}
@users_router.post("/auth/reset-password", deprecated=False)
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")
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)
return {"massage": "密码重置成功"}

View File

@ -1,5 +1,6 @@
import random
import re
from typing import Literal
import bcrypt
from fastapi import HTTPException
@ -38,6 +39,11 @@ async def validate_password(password: str):
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:
@ -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)
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}")
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)
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)
ops_dict = {
"reg" : "用户注册",
"reset" : "密码重置",
}
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>您正在进行 <b>{ops_dict[ops_type]}</b> 操作</p>
<p>
您的验证码是
<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)
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}")
if stored is None or stored != input_code:
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):
ok = await __verify_email_code(redis, email, input_code)
ok = await verify_email_code(redis, email, input_code)
if not ok:
return None

View File

@ -1,7 +1,8 @@
import re
from typing import Literal, Optional, Annotated
from fastapi import HTTPException
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
default_portrait_url = '#'
@ -12,11 +13,26 @@ EmailField = Annotated[str, Field(pattern=r"^[\w\.-]+@[\w\.-]+\.\w+$")]
class UserIn(BaseModel):
username: str
password: str
email: Optional[EmailField] = None
phone: ChinaPhone
email: str
phone: Optional[str] = None
lang_pref: Literal['jp', 'fr', 'private'] = "private"
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')
# @classmethod
# def validate_username(cls, v):
@ -75,12 +91,7 @@ class UserResetPhoneRequest(BaseModel):
phone_number: ChinaPhone
class UserDoesNotExistsError(HTTPException):
def __init__(self, message: str):
super().__init__(status_code=404, detail=message)
class VerifyCodeRequest(BaseModel):
class VerifyPhoneCodeRequest(BaseModel):
code: str
phone: ChinaPhone

View File

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

View File

@ -26,8 +26,8 @@ class User(Model):
name = fields.CharField(max_length=20, unique=True, description="用户名")
pwd_hashed = fields.CharField(max_length=60, 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="用户手机号")
email = fields.CharField(max_length=120, description="e-mail")
encrypted_phone = fields.CharField(max_length=11, description="用户手机号", null=True)
language = fields.ForeignKeyField("models.Language", related_name="users", on_delete=fields.CASCADE)
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.core.redis import init_redis, close_redis
from app.utils.phone_encrypt import PhoneEncrypt
from settings import ONLINE_SETTINGS
from settings import TORTOISE_ORM
@asynccontextmanager
@ -41,7 +41,7 @@ app.add_middleware(
register_tortoise(
app=app,
config=ONLINE_SETTINGS,
config=TORTOISE_ORM,
)
app.include_router(users_router, tags=["User API"], prefix="/users")

View File

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

View File

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

955
uv.lock

File diff suppressed because it is too large Load Diff