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:
Your Name
2026-02-09 18:49:47 +08:00
co-authored by Claude Opus 4.6
parent 864af19011
commit d4081345f7
18 changed files with 592 additions and 89 deletions
+13
View File
@@ -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():
"""清除限流中间件的请求记录,防止测试间互相影响"""
+25 -37
View File
@@ -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
+1
View File
@@ -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()
+1
View File
@@ -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()
+1
View File
@@ -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()
+1
View File
@@ -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()
+1
View File
@@ -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()
+1
View File
@@ -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()