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:
parent
864af19011
commit
d4081345f7
@ -13,6 +13,7 @@ from app.schemas.auth import (
|
||||
LoginResponse,
|
||||
RefreshTokenRequest,
|
||||
RefreshTokenResponse,
|
||||
SendEmailCodeRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services.auth import (
|
||||
@ -27,11 +28,65 @@ from app.services.auth import (
|
||||
decode_token,
|
||||
get_user_organization_info,
|
||||
)
|
||||
from app.services.verification import generate_code, verify_code
|
||||
from app.services.email import send_verification_email
|
||||
from app.services.audit import log_action
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/send-code")
|
||||
async def send_email_code(
|
||||
request: SendEmailCodeRequest,
|
||||
req: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
发送邮箱验证码
|
||||
|
||||
- purpose=register: 注册用,邮箱不能已被注册
|
||||
- purpose=login: 登录用,邮箱必须已注册
|
||||
- purpose=reset_password: 重置密码用,邮箱必须已注册
|
||||
- 60秒内不可重复发送
|
||||
"""
|
||||
email = request.email
|
||||
purpose = request.purpose
|
||||
|
||||
# 根据用途检查邮箱状态
|
||||
existing = await get_user_by_email(db, email)
|
||||
|
||||
if purpose == "register":
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
elif purpose in ("login", "reset_password"):
|
||||
if not existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱未注册",
|
||||
)
|
||||
|
||||
# 生成验证码
|
||||
code, error = generate_code(email, purpose)
|
||||
if error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=error,
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
sent = send_verification_email(email, code, purpose)
|
||||
if not sent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="验证码发送失败,请稍后重试",
|
||||
)
|
||||
|
||||
return {"message": "验证码已发送", "expires_in": 300}
|
||||
|
||||
|
||||
@router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register(
|
||||
request: RegisterRequest,
|
||||
@ -41,24 +96,24 @@ async def register(
|
||||
"""
|
||||
用户注册
|
||||
|
||||
- 支持邮箱或手机号注册(至少提供一个)
|
||||
- 需要先调用 /auth/send-code 获取邮箱验证码
|
||||
- 验证码正确后完成注册
|
||||
- 注册后自动登录,返回 Token
|
||||
"""
|
||||
# 验证至少提供邮箱或手机号
|
||||
if not request.email and not request.phone:
|
||||
# 验证邮箱验证码
|
||||
if not verify_code(request.email, request.email_code, "register"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供邮箱或手机号",
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
|
||||
# 检查邮箱是否已存在
|
||||
if request.email:
|
||||
existing = await get_user_by_email(db, request.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
existing = await get_user_by_email(db, request.email)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册",
|
||||
)
|
||||
|
||||
# 检查手机号是否已存在
|
||||
if request.phone:
|
||||
@ -69,7 +124,7 @@ async def register(
|
||||
detail="该手机号已被注册",
|
||||
)
|
||||
|
||||
# 创建用户
|
||||
# 创建用户(邮箱已验证)
|
||||
user = await create_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
@ -77,6 +132,7 @@ async def register(
|
||||
password=request.password,
|
||||
name=request.name,
|
||||
role=request.role,
|
||||
is_verified=True,
|
||||
)
|
||||
|
||||
# 生成 Token
|
||||
@ -122,8 +178,8 @@ async def login(
|
||||
"""
|
||||
用户登录
|
||||
|
||||
- 支持邮箱+密码 或 手机号+密码 登录
|
||||
- 返回 accessToken 和 refreshToken
|
||||
- 支持邮箱+密码登录
|
||||
- 支持邮箱+验证码登录(需先调用 /auth/send-code)
|
||||
"""
|
||||
# 验证请求参数
|
||||
if not request.email and not request.phone:
|
||||
@ -132,19 +188,35 @@ async def login(
|
||||
detail="请提供邮箱或手机号",
|
||||
)
|
||||
|
||||
if not request.password:
|
||||
if not request.password and not request.email_code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请提供密码",
|
||||
detail="请提供密码或验证码",
|
||||
)
|
||||
|
||||
# 验证用户
|
||||
user = await authenticate_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
)
|
||||
user = None
|
||||
|
||||
# 验证码登录
|
||||
if request.email_code and request.email:
|
||||
if not verify_code(request.email, request.email_code, "login"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="验证码错误或已过期",
|
||||
)
|
||||
user = await get_user_by_email(db, request.email)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在",
|
||||
)
|
||||
else:
|
||||
# 密码登录
|
||||
user = await authenticate_user(
|
||||
db=db,
|
||||
email=request.email,
|
||||
phone=request.phone,
|
||||
password=request.password,
|
||||
)
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
|
||||
@ -39,6 +39,18 @@ class Settings(BaseSettings):
|
||||
OSS_BUCKET_NAME: str = "miaosi-files"
|
||||
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
||||
|
||||
# 邮件 SMTP
|
||||
SMTP_HOST: str = ""
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: str = ""
|
||||
SMTP_PASSWORD: str = ""
|
||||
SMTP_FROM_NAME: str = "秒思智能审核平台"
|
||||
SMTP_USE_SSL: bool = True
|
||||
|
||||
# 验证码
|
||||
VERIFICATION_CODE_EXPIRE_MINUTES: int = 5
|
||||
VERIFICATION_CODE_LENGTH: int = 6
|
||||
|
||||
# 加密密钥
|
||||
ENCRYPTION_KEY: str = ""
|
||||
|
||||
|
||||
@ -8,13 +8,28 @@ from app.models.user import UserRole
|
||||
|
||||
# ===== 请求 =====
|
||||
|
||||
class SendEmailCodeRequest(BaseModel):
|
||||
"""发送邮箱验证码请求"""
|
||||
email: EmailStr
|
||||
purpose: str = Field("register", pattern=r"^(register|login|reset_password)$")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"email": "user@example.com",
|
||||
"purpose": "register"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
"""注册请求"""
|
||||
email: Optional[EmailStr] = None
|
||||
email: EmailStr
|
||||
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
|
||||
password: str = Field(..., min_length=6, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
role: UserRole
|
||||
email_code: str = Field(..., min_length=4, max_length=8, description="邮箱验证码")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@ -22,17 +37,18 @@ class RegisterRequest(BaseModel):
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"name": "张三",
|
||||
"role": "creator"
|
||||
"role": "creator",
|
||||
"email_code": "123456"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登录请求(邮箱或手机号)"""
|
||||
"""登录请求(支持邮箱+密码 或 邮箱+验证码)"""
|
||||
email: Optional[EmailStr] = None
|
||||
phone: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
sms_code: Optional[str] = None # 短信验证码
|
||||
email_code: Optional[str] = None # 邮箱验证码
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
|
||||
@ -100,6 +100,7 @@ async def create_user(
|
||||
password: str,
|
||||
name: str,
|
||||
role: UserRole,
|
||||
is_verified: bool = False,
|
||||
) -> User:
|
||||
"""创建用户"""
|
||||
user_id = generate_id("U")
|
||||
@ -112,7 +113,7 @@ async def create_user(
|
||||
name=name,
|
||||
role=role,
|
||||
is_active=True,
|
||||
is_verified=False,
|
||||
is_verified=is_verified,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
|
||||
105
backend/app/services/email.py
Normal file
105
backend/app/services/email.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""
|
||||
邮件发送服务
|
||||
|
||||
开发环境:将验证码输出到控制台(不实际发送)。
|
||||
生产环境:通过 SMTP 发送邮件。
|
||||
"""
|
||||
import smtplib
|
||||
import logging
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_verification_email(to_email: str, code: str, purpose: str) -> MIMEMultipart:
|
||||
"""构建验证码邮件"""
|
||||
purpose_text = {
|
||||
"register": "注册账号",
|
||||
"login": "登录",
|
||||
"reset_password": "重置密码",
|
||||
}.get(purpose, "操作")
|
||||
|
||||
subject = f"【{settings.APP_NAME}】{purpose_text}验证码"
|
||||
html = f"""
|
||||
<div style="max-width: 480px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
|
||||
<div style="background: linear-gradient(135deg, #6366F1, #4F46E5); padding: 32px; border-radius: 12px 12px 0 0;">
|
||||
<h1 style="color: white; margin: 0; font-size: 24px;">{settings.APP_NAME}</h1>
|
||||
</div>
|
||||
<div style="background: #ffffff; padding: 32px; border: 1px solid #E5E7EB; border-top: none; border-radius: 0 0 12px 12px;">
|
||||
<p style="color: #374151; font-size: 16px; margin: 0 0 16px;">您好,</p>
|
||||
<p style="color: #374151; font-size: 16px; margin: 0 0 24px;">
|
||||
您正在{purpose_text},验证码为:
|
||||
</p>
|
||||
<div style="background: #F3F4F6; padding: 20px; border-radius: 8px; text-align: center; margin: 0 0 24px;">
|
||||
<span style="font-size: 32px; font-weight: bold; letter-spacing: 8px; color: #4F46E5;">{code}</span>
|
||||
</div>
|
||||
<p style="color: #6B7280; font-size: 14px; margin: 0 0 8px;">
|
||||
验证码 {settings.VERIFICATION_CODE_EXPIRE_MINUTES} 分钟内有效,请勿泄露给他人。
|
||||
</p>
|
||||
<p style="color: #9CA3AF; font-size: 12px; margin: 16px 0 0;">
|
||||
如非本人操作,请忽略此邮件。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_USER}>"
|
||||
msg["To"] = to_email
|
||||
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||
return msg
|
||||
|
||||
|
||||
def send_verification_email(to_email: str, code: str, purpose: str = "register") -> bool:
|
||||
"""
|
||||
发送验证码邮件。
|
||||
|
||||
开发环境下仅打印到控制台,不实际发送。
|
||||
返回 True 表示成功。
|
||||
"""
|
||||
purpose_text = {
|
||||
"register": "注册",
|
||||
"login": "登录",
|
||||
"reset_password": "重置密码",
|
||||
}.get(purpose, "操作")
|
||||
|
||||
# 开发环境:仅打印到控制台
|
||||
if settings.ENVIRONMENT == "development" or not settings.SMTP_HOST:
|
||||
logger.info(
|
||||
"\n"
|
||||
"============================================\n"
|
||||
" 邮箱验证码 (开发模式 - 未实际发送)\n"
|
||||
" 收件人: %s\n"
|
||||
" 用途: %s\n"
|
||||
" 验证码: %s\n"
|
||||
" 有效期: %d 分钟\n"
|
||||
"============================================",
|
||||
to_email, purpose_text, code,
|
||||
settings.VERIFICATION_CODE_EXPIRE_MINUTES,
|
||||
)
|
||||
return True
|
||||
|
||||
# 生产环境:通过 SMTP 发送
|
||||
try:
|
||||
msg = _build_verification_email(to_email, code, purpose)
|
||||
|
||||
if settings.SMTP_USE_SSL:
|
||||
server = smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
else:
|
||||
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
|
||||
server.starttls()
|
||||
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.sendmail(settings.SMTP_USER, [to_email], msg.as_string())
|
||||
server.quit()
|
||||
|
||||
logger.info("验证码邮件已发送: %s (%s)", to_email, purpose_text)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("发送验证码邮件失败: %s", to_email)
|
||||
return False
|
||||
96
backend/app/services/verification.py
Normal file
96
backend/app/services/verification.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""
|
||||
验证码服务
|
||||
|
||||
使用内存存储验证码,支持 TTL 自动过期。
|
||||
生产环境建议替换为 Redis 存储。
|
||||
"""
|
||||
import secrets
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 内存存储: { "email:purpose" -> (code, expire_timestamp) }
|
||||
_code_store: dict[str, tuple[str, float]] = {}
|
||||
|
||||
# 发送频率限制: { "email:purpose" -> last_send_timestamp }
|
||||
_rate_limit: dict[str, float] = {}
|
||||
|
||||
# 最小发送间隔(秒)
|
||||
SEND_INTERVAL = 60
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""清理过期的验证码"""
|
||||
now = time.time()
|
||||
expired_keys = [k for k, (_, exp) in _code_store.items() if now > exp]
|
||||
for k in expired_keys:
|
||||
del _code_store[k]
|
||||
|
||||
|
||||
def generate_code(email: str, purpose: str = "register") -> tuple[str, Optional[str]]:
|
||||
"""
|
||||
生成验证码并存储。
|
||||
|
||||
返回 (code, error)。
|
||||
error 为 None 表示成功,否则返回错误信息。
|
||||
"""
|
||||
_cleanup_expired()
|
||||
|
||||
key = f"{email}:{purpose}"
|
||||
|
||||
# 检查发送频率
|
||||
now = time.time()
|
||||
last_sent = _rate_limit.get(key, 0)
|
||||
if now - last_sent < SEND_INTERVAL:
|
||||
remaining = int(SEND_INTERVAL - (now - last_sent))
|
||||
return "", f"发送过于频繁,请 {remaining} 秒后重试"
|
||||
|
||||
# 生成验证码
|
||||
code = "".join(str(secrets.randbelow(10)) for _ in range(settings.VERIFICATION_CODE_LENGTH))
|
||||
|
||||
# 存储(带 TTL)
|
||||
expire_at = now + settings.VERIFICATION_CODE_EXPIRE_MINUTES * 60
|
||||
_code_store[key] = (code, expire_at)
|
||||
_rate_limit[key] = now
|
||||
|
||||
logger.info("验证码已生成: email=%s, purpose=%s", email, purpose)
|
||||
return code, None
|
||||
|
||||
|
||||
def verify_code(email: str, code: str, purpose: str = "register") -> bool:
|
||||
"""
|
||||
验证验证码是否正确。
|
||||
|
||||
验证成功后自动删除验证码(一次性使用)。
|
||||
"""
|
||||
_cleanup_expired()
|
||||
|
||||
key = f"{email}:{purpose}"
|
||||
stored = _code_store.get(key)
|
||||
|
||||
if not stored:
|
||||
return False
|
||||
|
||||
stored_code, expire_at = stored
|
||||
|
||||
# 已过期
|
||||
if time.time() > expire_at:
|
||||
del _code_store[key]
|
||||
return False
|
||||
|
||||
# 验证码匹配
|
||||
if stored_code == code:
|
||||
del _code_store[key] # 一次性使用
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def clear_all() -> None:
|
||||
"""清除所有验证码(用于测试)"""
|
||||
_code_store.clear()
|
||||
_rate_limit.clear()
|
||||
@ -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()
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react'
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock } from 'lucide-react'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, KeyRound } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
type LoginMode = 'password' | 'code'
|
||||
|
||||
function LoginForm() {
|
||||
const router = useRouter()
|
||||
@ -12,13 +16,24 @@ function LoginForm() {
|
||||
const { login } = useAuth()
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [emailCode, setEmailCode] = useState('')
|
||||
const [loginMode, setLoginMode] = useState<LoginMode>('password')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false)
|
||||
const [codeSending, setCodeSending] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 如果 URL 有 role 参数,自动触发 demo 登录
|
||||
const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => {
|
||||
const emailMap = {
|
||||
creator: 'creator@demo.com',
|
||||
@ -59,12 +74,43 @@ function LoginForm() {
|
||||
}
|
||||
}, [roleFromUrl])
|
||||
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请先输入邮箱')
|
||||
return
|
||||
}
|
||||
if (countdown > 0) return
|
||||
|
||||
setError('')
|
||||
setCodeSending(true)
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setCountdown(60)
|
||||
return
|
||||
}
|
||||
|
||||
await api.sendEmailCode({ email, purpose: 'login' })
|
||||
setCountdown(60)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : '发送验证码失败'
|
||||
setError(error)
|
||||
} finally {
|
||||
setCodeSending(false)
|
||||
}
|
||||
}, [email, countdown])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
|
||||
const result = await login({ email, password })
|
||||
const credentials = loginMode === 'code'
|
||||
? { email, email_code: emailCode }
|
||||
: { email, password }
|
||||
|
||||
const result = await login(credentials)
|
||||
|
||||
if (result.success) {
|
||||
const stored = localStorage.getItem('miaosi_user')
|
||||
@ -114,6 +160,32 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 登录方式切换 */}
|
||||
<div className="flex bg-bg-elevated rounded-xl p-1 border border-border-subtle">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('password'); setError('') }}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
loginMode === 'password'
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setLoginMode('code'); setError('') }}
|
||||
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
loginMode === 'code'
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
@ -138,20 +210,48 @@ function LoginForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
{loginMode === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">密码</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">验证码</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={codeSending || countdown > 0 || !email}
|
||||
className="px-4 py-3.5 rounded-xl bg-accent-indigo/10 text-accent-indigo font-medium text-sm whitespace-nowrap hover:bg-accent-indigo/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{codeSending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone } from 'lucide-react'
|
||||
import { ShieldCheck, AlertCircle, ArrowLeft, Mail, Lock, User, Phone, KeyRound } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { UserRole } from '@/lib/api'
|
||||
import { api } from '@/lib/api'
|
||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||||
|
||||
const roleOptions: { value: UserRole; label: string; desc: string }[] = [
|
||||
{ value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' },
|
||||
@ -21,9 +23,47 @@ export default function RegisterPage() {
|
||||
const [phone, setPhone] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [emailCode, setEmailCode] = useState('')
|
||||
const [role, setRole] = useState<UserRole>('creator')
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [codeSending, setCodeSending] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
// 倒计时
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown(countdown - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleSendCode = useCallback(async () => {
|
||||
if (!email) {
|
||||
setError('请先输入邮箱')
|
||||
return
|
||||
}
|
||||
if (countdown > 0) return
|
||||
|
||||
setError('')
|
||||
setCodeSending(true)
|
||||
|
||||
try {
|
||||
if (USE_MOCK) {
|
||||
// Mock: 模拟发送
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
setCountdown(60)
|
||||
return
|
||||
}
|
||||
|
||||
await api.sendEmailCode({ email, purpose: 'register' })
|
||||
setCountdown(60)
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : '发送验证码失败'
|
||||
setError(error)
|
||||
} finally {
|
||||
setCodeSending(false)
|
||||
}
|
||||
}, [email, countdown])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@ -33,8 +73,12 @@ export default function RegisterPage() {
|
||||
setError('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!email && !phone) {
|
||||
setError('请填写邮箱或手机号')
|
||||
if (!email) {
|
||||
setError('请填写邮箱')
|
||||
return
|
||||
}
|
||||
if (!emailCode && !USE_MOCK) {
|
||||
setError('请输入验证码')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
@ -50,10 +94,11 @@ export default function RegisterPage() {
|
||||
|
||||
const result = await register({
|
||||
name: name.trim(),
|
||||
email: email || undefined,
|
||||
email,
|
||||
phone: phone || undefined,
|
||||
password,
|
||||
role,
|
||||
email_code: emailCode || '000000',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
@ -158,10 +203,37 @@ export default function RegisterPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">邮箱验证码</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="请输入验证码"
|
||||
value={emailCode}
|
||||
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
className="w-full pl-12 pr-4 py-3.5 bg-bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-indigo focus:border-transparent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={codeSending || countdown > 0 || !email}
|
||||
className="px-4 py-3.5 rounded-xl bg-accent-indigo/10 text-accent-indigo font-medium text-sm whitespace-nowrap hover:bg-accent-indigo/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{codeSending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 手机号 */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-text-primary">
|
||||
|
||||
@ -94,7 +94,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (!mockUser) {
|
||||
return { success: false, error: '用户不存在' }
|
||||
}
|
||||
if (mockUser.password !== credentials.password) {
|
||||
// 验证码登录或密码登录
|
||||
if (credentials.email_code) {
|
||||
// 验证码登录 mock: 任何验证码都通过
|
||||
} else if (mockUser.password !== credentials.password) {
|
||||
return { success: false, error: '密码错误' }
|
||||
}
|
||||
const { password: _, ...userWithoutPassword } = mockUser
|
||||
|
||||
@ -93,15 +93,26 @@ export interface LoginRequest {
|
||||
email?: string
|
||||
phone?: string
|
||||
password?: string
|
||||
sms_code?: string
|
||||
email_code?: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email?: string
|
||||
email: string
|
||||
phone?: string
|
||||
password: string
|
||||
name: string
|
||||
role: UserRole
|
||||
email_code: string
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string
|
||||
purpose: 'register' | 'login' | 'reset_password'
|
||||
}
|
||||
|
||||
export interface SendEmailCodeResponse {
|
||||
message: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
@ -257,6 +268,14 @@ class ApiClient {
|
||||
|
||||
// ==================== 认证 ====================
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*/
|
||||
async sendEmailCode(data: SendEmailCodeRequest): Promise<SendEmailCodeResponse> {
|
||||
const response = await this.client.post<SendEmailCodeResponse>('/auth/send-code', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user