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
parent 864af19011
commit d4081345f7
18 changed files with 592 additions and 89 deletions

View File

@ -13,6 +13,7 @@ from app.schemas.auth import (
LoginResponse, LoginResponse,
RefreshTokenRequest, RefreshTokenRequest,
RefreshTokenResponse, RefreshTokenResponse,
SendEmailCodeRequest,
UserResponse, UserResponse,
) )
from app.services.auth import ( from app.services.auth import (
@ -27,11 +28,65 @@ from app.services.auth import (
decode_token, decode_token,
get_user_organization_info, 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 from app.services.audit import log_action
router = APIRouter(prefix="/auth", tags=["认证"]) 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) @router.post("/register", response_model=LoginResponse, status_code=status.HTTP_201_CREATED)
async def register( async def register(
request: RegisterRequest, request: RegisterRequest,
@ -41,18 +96,18 @@ async def register(
""" """
用户注册 用户注册
- 支持邮箱或手机号注册至少提供一个 - 需要先调用 /auth/send-code 获取邮箱验证码
- 验证码正确后完成注册
- 注册后自动登录返回 Token - 注册后自动登录返回 Token
""" """
# 验证至少提供邮箱或手机号 # 验证邮箱验证码
if not request.email and not request.phone: if not verify_code(request.email, request.email_code, "register"):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="请提供邮箱或手机号", detail="验证码错误或已过期",
) )
# 检查邮箱是否已存在 # 检查邮箱是否已存在
if request.email:
existing = await get_user_by_email(db, request.email) existing = await get_user_by_email(db, request.email)
if existing: if existing:
raise HTTPException( raise HTTPException(
@ -69,7 +124,7 @@ async def register(
detail="该手机号已被注册", detail="该手机号已被注册",
) )
# 创建用户 # 创建用户(邮箱已验证)
user = await create_user( user = await create_user(
db=db, db=db,
email=request.email, email=request.email,
@ -77,6 +132,7 @@ async def register(
password=request.password, password=request.password,
name=request.name, name=request.name,
role=request.role, role=request.role,
is_verified=True,
) )
# 生成 Token # 生成 Token
@ -122,8 +178,8 @@ async def login(
""" """
用户登录 用户登录
- 支持邮箱+密码 手机号+密码 登录 - 支持邮箱+密码登录
- 返回 accessToken refreshToken - 支持邮箱+验证码登录需先调用 /auth/send-code
""" """
# 验证请求参数 # 验证请求参数
if not request.email and not request.phone: if not request.email and not request.phone:
@ -132,13 +188,29 @@ async def login(
detail="请提供邮箱或手机号", detail="请提供邮箱或手机号",
) )
if not request.password: if not request.password and not request.email_code:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="请提供密码", detail="请提供密码或验证码",
) )
# 验证用户 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( user = await authenticate_user(
db=db, db=db,
email=request.email, email=request.email,

View File

@ -39,6 +39,18 @@ class Settings(BaseSettings):
OSS_BUCKET_NAME: str = "miaosi-files" OSS_BUCKET_NAME: str = "miaosi-files"
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com 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 = "" ENCRYPTION_KEY: str = ""

View File

@ -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): class RegisterRequest(BaseModel):
"""注册请求""" """注册请求"""
email: Optional[EmailStr] = None email: EmailStr
phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$")
password: str = Field(..., min_length=6, max_length=128) password: str = Field(..., min_length=6, max_length=128)
name: str = Field(..., min_length=1, max_length=100) name: str = Field(..., min_length=1, max_length=100)
role: UserRole role: UserRole
email_code: str = Field(..., min_length=4, max_length=8, description="邮箱验证码")
class Config: class Config:
json_schema_extra = { json_schema_extra = {
@ -22,17 +37,18 @@ class RegisterRequest(BaseModel):
"email": "user@example.com", "email": "user@example.com",
"password": "password123", "password": "password123",
"name": "张三", "name": "张三",
"role": "creator" "role": "creator",
"email_code": "123456"
} }
} }
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
"""登录请求(邮箱或手机号""" """登录请求(支持邮箱+密码 或 邮箱+验证码"""
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
phone: Optional[str] = None phone: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
sms_code: Optional[str] = None # 短信验证码 email_code: Optional[str] = None # 邮箱验证码
class Config: class Config:
json_schema_extra = { json_schema_extra = {

View File

@ -100,6 +100,7 @@ async def create_user(
password: str, password: str,
name: str, name: str,
role: UserRole, role: UserRole,
is_verified: bool = False,
) -> User: ) -> User:
"""创建用户""" """创建用户"""
user_id = generate_id("U") user_id = generate_id("U")
@ -112,7 +113,7 @@ async def create_user(
name=name, name=name,
role=role, role=role,
is_active=True, is_active=True,
is_verified=False, is_verified=is_verified,
) )
db.add(user) db.add(user)

View 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

View 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()

View File

@ -21,6 +21,8 @@ from app.services.health import (
get_health_checker, get_health_checker,
) )
from app.middleware.rate_limit import RateLimitMiddleware 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") @pytest.fixture(scope="session")
@ -32,6 +34,17 @@ def event_loop():
loop.close() 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) @pytest.fixture(autouse=True)
def _clear_rate_limiter(): def _clear_rate_limiter():
"""清除限流中间件的请求记录,防止测试间互相影响""" """清除限流中间件的请求记录,防止测试间互相影响"""

View File

@ -49,12 +49,12 @@ async def register_user(
) -> dict: ) -> dict:
"""注册用户并返回响应对象""" """注册用户并返回响应对象"""
payload = { payload = {
"email": email,
"password": password, "password": password,
"name": name, "name": name,
"role": role, "role": role,
"email_code": "000000",
} }
if email is not None:
payload["email"] = email
if phone is not None: if phone is not None:
payload["phone"] = phone payload["phone"] = phone
response = await client.post("/api/v1/auth/register", json=payload) response = await client.post("/api/v1/auth/register", json=payload)
@ -113,25 +113,10 @@ class TestRegister:
assert user["email"] == "user@example.com" assert user["email"] == "user@example.com"
assert user["name"] == "测试用户" assert user["name"] == "测试用户"
assert user["role"] == "brand" assert user["role"] == "brand"
assert user["is_verified"] is False assert user["is_verified"] is True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_with_phone_success(self, client: AsyncClient): async def test_register_with_email_and_phone(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):
"""同时提供邮箱和手机号注册成功""" """同时提供邮箱和手机号注册成功"""
resp = await register_user( resp = await register_user(
client, client,
@ -147,14 +132,17 @@ class TestRegister:
assert user["role"] == "agency" assert user["role"] == "agency"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_missing_email_and_phone_returns_400(self, client: AsyncClient): async def test_register_missing_email_returns_422(self, client: AsyncClient):
"""不提供邮箱和手机号时返回 400""" """不提供邮箱时返回 422邮箱为必填字段"""
resp = await register_user(client, email=None, phone=None) payload = {
assert resp.status_code == 400 "phone": "13800138000",
"password": "Test1234!",
data = resp.json() "name": "测试用户",
assert "detail" in data "role": "brand",
assert "邮箱" in data["detail"] or "手机号" in data["detail"] "email_code": "000000",
}
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_duplicate_email_returns_400(self, client: AsyncClient): 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): async def test_register_duplicate_phone_returns_400(self, client: AsyncClient):
"""重复手机号注册返回 400""" """重复手机号注册返回 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 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 assert resp2.status_code == 400
data = resp2.json() data = resp2.json()
@ -238,7 +226,7 @@ class TestRegister:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_register_invalid_phone_format_returns_422(self, client: AsyncClient): async def test_register_invalid_phone_format_returns_422(self, client: AsyncClient):
"""无效的手机号格式返回 422 (不匹配 ^1[3-9]\\d{9}$)""" """无效的手机号格式返回 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 assert resp.status_code == 422
@pytest.mark.asyncio @pytest.mark.asyncio
@ -341,9 +329,9 @@ class TestLogin:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_with_phone_success(self, client: AsyncClient): 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!") resp = await login_user(client, email=None, phone="13800138001", password="Test1234!")
assert resp.status_code == 200 assert resp.status_code == 200
@ -378,14 +366,14 @@ class TestLogin:
assert "邮箱" in data["detail"] or "手机号" in data["detail"] assert "邮箱" in data["detail"] or "手机号" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_missing_password_returns_400(self, client: AsyncClient): async def test_login_missing_password_and_code_returns_400(self, client: AsyncClient):
"""不提供密码登录时返回 400""" """不提供密码和验证码登录时返回 400"""
payload = {"email": "test@example.com"} payload = {"email": "test@example.com"}
resp = await client.post("/api/v1/auth/login", json=payload) resp = await client.post("/api/v1/auth/login", json=payload)
assert resp.status_code == 400 assert resp.status_code == 400
data = resp.json() data = resp.json()
assert "密码" in data["detail"] assert "密码" in data["detail"] or "验证码" in data["detail"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_login_disabled_user_returns_403(self, client: AsyncClient): 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") resp1 = await register_user(client, email="user1@example.com", name="用户一", role="brand")
resp2 = await register_user(client, email="user2@example.com", name="用户二", role="agency") 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 resp1.status_code == 201
assert resp2.status_code == 201 assert resp2.status_code == 201

View File

@ -64,6 +64,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -58,6 +58,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -71,6 +71,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -72,6 +72,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -73,6 +73,7 @@ async def _register(client: AsyncClient, role: str, name: str | None = None):
"password": "test123456", "password": "test123456",
"name": name or f"Test {role.title()}", "name": name or f"Test {role.title()}",
"role": role, "role": role,
"email_code": "000000",
}) })
assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}" assert resp.status_code == 201, f"Registration failed for {role}: {resp.text}"
data = resp.json() data = resp.json()

View File

@ -1,10 +1,14 @@
'use client' 'use client'
import { useState, useEffect, Suspense } from 'react' import { useState, useEffect, useCallback, Suspense } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext' 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 Link from 'next/link'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
type LoginMode = 'password' | 'code'
function LoginForm() { function LoginForm() {
const router = useRouter() const router = useRouter()
@ -12,13 +16,24 @@ function LoginForm() {
const { login } = useAuth() const { login } = useAuth()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [emailCode, setEmailCode] = useState('')
const [loginMode, setLoginMode] = useState<LoginMode>('password')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false) const [autoLoginAttempted, setAutoLoginAttempted] = useState(false)
const [codeSending, setCodeSending] = useState(false)
const [countdown, setCountdown] = useState(0)
// 如果 URL 有 role 参数,自动触发 demo 登录 // 如果 URL 有 role 参数,自动触发 demo 登录
const roleFromUrl = searchParams.get('role') as 'creator' | 'agency' | 'brand' | null 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 handleDemoLogin = async (role: 'creator' | 'agency' | 'brand') => {
const emailMap = { const emailMap = {
creator: 'creator@demo.com', creator: 'creator@demo.com',
@ -59,12 +74,43 @@ function LoginForm() {
} }
}, [roleFromUrl]) }, [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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
setIsLoading(true) 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) { if (result.success) {
const stored = localStorage.getItem('miaosi_user') const stored = localStorage.getItem('miaosi_user')
@ -114,6 +160,32 @@ function LoginForm() {
</div> </div>
</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"> <form onSubmit={handleSubmit} className="space-y-5">
{error && ( {error && (
@ -138,6 +210,7 @@ function LoginForm() {
</div> </div>
</div> </div>
{loginMode === 'password' ? (
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-text-primary"></label> <label className="block text-sm font-medium text-text-primary"></label>
<div className="relative"> <div className="relative">
@ -152,6 +225,33 @@ function LoginForm() {
/> />
</div> </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 <button
type="submit" type="submit"

View File

@ -1,11 +1,13 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useAuth } from '@/contexts/AuthContext' 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 Link from 'next/link'
import type { UserRole } from '@/lib/api' 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 }[] = [ const roleOptions: { value: UserRole; label: string; desc: string }[] = [
{ value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' }, { value: 'brand', label: '品牌方', desc: '创建项目、管理代理商、配置审核规则' },
@ -21,9 +23,47 @@ export default function RegisterPage() {
const [phone, setPhone] = useState('') const [phone, setPhone] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [emailCode, setEmailCode] = useState('')
const [role, setRole] = useState<UserRole>('creator') const [role, setRole] = useState<UserRole>('creator')
const [error, setError] = useState('') const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false) 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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@ -33,8 +73,12 @@ export default function RegisterPage() {
setError('请输入用户名') setError('请输入用户名')
return return
} }
if (!email && !phone) { if (!email) {
setError('请填写邮箱或手机号') setError('请填写邮箱')
return
}
if (!emailCode && !USE_MOCK) {
setError('请输入验证码')
return return
} }
if (password.length < 6) { if (password.length < 6) {
@ -50,10 +94,11 @@ export default function RegisterPage() {
const result = await register({ const result = await register({
name: name.trim(), name: name.trim(),
email: email || undefined, email,
phone: phone || undefined, phone: phone || undefined,
password, password,
role, role,
email_code: emailCode || '000000',
}) })
if (result.success) { if (result.success) {
@ -158,10 +203,37 @@ export default function RegisterPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} 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" 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>
{/* 验证码 */}
<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"> <div className="space-y-2">
<label className="block text-sm font-medium text-text-primary"> <label className="block text-sm font-medium text-text-primary">

View File

@ -94,7 +94,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!mockUser) { if (!mockUser) {
return { success: false, error: '用户不存在' } return { success: false, error: '用户不存在' }
} }
if (mockUser.password !== credentials.password) { // 验证码登录或密码登录
if (credentials.email_code) {
// 验证码登录 mock: 任何验证码都通过
} else if (mockUser.password !== credentials.password) {
return { success: false, error: '密码错误' } return { success: false, error: '密码错误' }
} }
const { password: _, ...userWithoutPassword } = mockUser const { password: _, ...userWithoutPassword } = mockUser

View File

@ -93,15 +93,26 @@ export interface LoginRequest {
email?: string email?: string
phone?: string phone?: string
password?: string password?: string
sms_code?: string email_code?: string
} }
export interface RegisterRequest { export interface RegisterRequest {
email?: string email: string
phone?: string phone?: string
password: string password: string
name: string name: string
role: UserRole 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 { 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
}
/** /**
* *
*/ */