feat: 实现邮箱验证码注册/登录功能
- 后端: 新增验证码服务(生成/存储/验证)和邮件发送服务(开发环境控制台输出) - 后端: 新增 POST /auth/send-code 端点,支持注册/登录/重置密码三种用途 - 后端: 注册流程要求邮箱验证码,验证通过后 is_verified=True - 后端: 登录支持邮箱+密码 或 邮箱+验证码 两种方式 - 前端: 注册页增加验证码输入框和获取验证码按钮(60秒倒计时) - 前端: 登录页增加密码登录/验证码登录双Tab切换 - 测试: conftest 添加 bypass_verification fixture,所有 367 测试通过 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
864af19011
commit
d4081345f7
@@ -21,6 +21,8 @@ from app.services.health import (
|
||||
get_health_checker,
|
||||
)
|
||||
from app.middleware.rate_limit import RateLimitMiddleware
|
||||
from app.services import verification as verification_module
|
||||
from app.api import auth as auth_api_module
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -32,6 +34,17 @@ def event_loop():
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _bypass_verification(monkeypatch):
|
||||
"""测试环境中跳过验证码验证,所有验证码校验直接通过"""
|
||||
_always_true = lambda email, code, purpose="register": True
|
||||
monkeypatch.setattr(verification_module, "verify_code", _always_true)
|
||||
monkeypatch.setattr(auth_api_module, "verify_code", _always_true)
|
||||
verification_module.clear_all()
|
||||
yield
|
||||
verification_module.clear_all()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_rate_limiter():
|
||||
"""清除限流中间件的请求记录,防止测试间互相影响"""
|
||||
|
||||
@@ -49,12 +49,12 @@ async def register_user(
|
||||
) -> dict:
|
||||
"""注册用户并返回响应对象"""
|
||||
payload = {
|
||||
"email": email,
|
||||
"password": password,
|
||||
"name": name,
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
}
|
||||
if email is not None:
|
||||
payload["email"] = email
|
||||
if phone is not None:
|
||||
payload["phone"] = phone
|
||||
response = await client.post("/api/v1/auth/register", json=payload)
|
||||
@@ -113,25 +113,10 @@ class TestRegister:
|
||||
assert user["email"] == "user@example.com"
|
||||
assert user["name"] == "测试用户"
|
||||
assert user["role"] == "brand"
|
||||
assert user["is_verified"] is False
|
||||
assert user["is_verified"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_phone_success(self, client: AsyncClient):
|
||||
"""通过手机号注册成功"""
|
||||
resp = await register_user(client, email=None, phone="13800138000", role="creator")
|
||||
assert resp.status_code == 201
|
||||
|
||||
data = resp.json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
|
||||
user = data["user"]
|
||||
assert user["phone"] == "13800138000"
|
||||
assert user["email"] is None
|
||||
assert user["role"] == "creator"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_both_email_and_phone(self, client: AsyncClient):
|
||||
async def test_register_with_email_and_phone(self, client: AsyncClient):
|
||||
"""同时提供邮箱和手机号注册成功"""
|
||||
resp = await register_user(
|
||||
client,
|
||||
@@ -147,14 +132,17 @@ class TestRegister:
|
||||
assert user["role"] == "agency"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_missing_email_and_phone_returns_400(self, client: AsyncClient):
|
||||
"""不提供邮箱和手机号时返回 400"""
|
||||
resp = await register_user(client, email=None, phone=None)
|
||||
assert resp.status_code == 400
|
||||
|
||||
data = resp.json()
|
||||
assert "detail" in data
|
||||
assert "邮箱" in data["detail"] or "手机号" in data["detail"]
|
||||
async def test_register_missing_email_returns_422(self, client: AsyncClient):
|
||||
"""不提供邮箱时返回 422(邮箱为必填字段)"""
|
||||
payload = {
|
||||
"phone": "13800138000",
|
||||
"password": "Test1234!",
|
||||
"name": "测试用户",
|
||||
"role": "brand",
|
||||
"email_code": "000000",
|
||||
}
|
||||
resp = await client.post("/api/v1/auth/register", json=payload)
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_email_returns_400(self, client: AsyncClient):
|
||||
@@ -174,11 +162,11 @@ class TestRegister:
|
||||
async def test_register_duplicate_phone_returns_400(self, client: AsyncClient):
|
||||
"""重复手机号注册返回 400"""
|
||||
# 第一次注册
|
||||
resp1 = await register_user(client, email=None, phone="13800000001")
|
||||
resp1 = await register_user(client, email="phone1@example.com", phone="13800000001")
|
||||
assert resp1.status_code == 201
|
||||
|
||||
# 第二次用相同手机号注册
|
||||
resp2 = await register_user(client, email=None, phone="13800000001", name="另一个用户")
|
||||
resp2 = await register_user(client, email="phone2@example.com", phone="13800000001", name="另一个用户")
|
||||
assert resp2.status_code == 400
|
||||
|
||||
data = resp2.json()
|
||||
@@ -238,7 +226,7 @@ class TestRegister:
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_invalid_phone_format_returns_422(self, client: AsyncClient):
|
||||
"""无效的手机号格式返回 422 (不匹配 ^1[3-9]\\d{9}$)"""
|
||||
resp = await register_user(client, email=None, phone="12345")
|
||||
resp = await register_user(client, email="badphone@example.com", phone="12345")
|
||||
assert resp.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -341,9 +329,9 @@ class TestLogin:
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_with_phone_success(self, client: AsyncClient):
|
||||
"""通过手机号+密码登录成功"""
|
||||
# 先注册
|
||||
await register_user(client, email=None, phone="13800138001", password="Test1234!")
|
||||
# 再登录
|
||||
# 先注册(带邮箱+手机号)
|
||||
await register_user(client, email="phonelogin@example.com", phone="13800138001", password="Test1234!")
|
||||
# 用手机号登录
|
||||
resp = await login_user(client, email=None, phone="13800138001", password="Test1234!")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -378,14 +366,14 @@ class TestLogin:
|
||||
assert "邮箱" in data["detail"] or "手机号" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_missing_password_returns_400(self, client: AsyncClient):
|
||||
"""不提供密码登录时返回 400"""
|
||||
async def test_login_missing_password_and_code_returns_400(self, client: AsyncClient):
|
||||
"""不提供密码和验证码登录时返回 400"""
|
||||
payload = {"email": "test@example.com"}
|
||||
resp = await client.post("/api/v1/auth/login", json=payload)
|
||||
assert resp.status_code == 400
|
||||
|
||||
data = resp.json()
|
||||
assert "密码" in data["detail"]
|
||||
assert "密码" in data["detail"] or "验证码" in data["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_disabled_user_returns_403(self, client: AsyncClient):
|
||||
@@ -804,7 +792,7 @@ class TestAuthEndToEnd:
|
||||
"""多用户注册不会互相影响"""
|
||||
resp1 = await register_user(client, email="user1@example.com", name="用户一", role="brand")
|
||||
resp2 = await register_user(client, email="user2@example.com", name="用户二", role="agency")
|
||||
resp3 = await register_user(client, email=None, phone="13700137001", name="用户三", role="creator")
|
||||
resp3 = await register_user(client, email="user3@example.com", phone="13700137001", name="用户三", role="creator")
|
||||
|
||||
assert resp1.status_code == 201
|
||||
assert resp2.status_code == 201
|
||||
|
||||
@@ -64,6 +64,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
@@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
@@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
@@ -71,6 +71,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
@@ -72,6 +72,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
@@ -73,6 +73,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
|
||||
"password": "test123456",
|
||||
"name": name or f"Test {role.title()}",
|
||||
"role": role,
|
||||
"email_code": "000000",
|
||||
})
|
||||
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
|
||||
data = resp.json()
|
||||
|
||||
Reference in New Issue
Block a user