1
This commit is contained in:
parent
bc16d2f074
commit
82b786f513
@ -0,0 +1,59 @@
|
|||||||
|
"""add email verification codes table
|
||||||
|
|
||||||
|
Revision ID: 20260427_add_email_codes
|
||||||
|
Revises: 20260427_create_memberships
|
||||||
|
Create Date: 2026-04-27 22:20:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "20260427_add_email_codes"
|
||||||
|
down_revision = "20260427_create_memberships"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
|
||||||
|
if "email_verification_codes" not in set(inspector.get_table_names()):
|
||||||
|
op.create_table(
|
||||||
|
"email_verification_codes",
|
||||||
|
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
|
||||||
|
sa.Column("email", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("purpose", sa.String(length=50), nullable=False),
|
||||||
|
sa.Column("code_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("consumed_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_email_verification_codes_email",
|
||||||
|
"email_verification_codes",
|
||||||
|
["email"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_email_verification_codes_purpose",
|
||||||
|
"email_verification_codes",
|
||||||
|
["purpose"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
tables = set(inspector.get_table_names())
|
||||||
|
if "email_verification_codes" in tables:
|
||||||
|
indexes = {index["name"] for index in inspector.get_indexes("email_verification_codes")}
|
||||||
|
if "ix_email_verification_codes_purpose" in indexes:
|
||||||
|
op.drop_index("ix_email_verification_codes_purpose", table_name="email_verification_codes")
|
||||||
|
if "ix_email_verification_codes_email" in indexes:
|
||||||
|
op.drop_index("ix_email_verification_codes_email", table_name="email_verification_codes")
|
||||||
|
op.drop_table("email_verification_codes")
|
||||||
@ -12,8 +12,18 @@ from app.schemas.auth import (
|
|||||||
InviteCodeClassPreview,
|
InviteCodeClassPreview,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
|
SendEmailCodeRequest,
|
||||||
)
|
)
|
||||||
from app.schemas.user import TokenResponse, UserOut, build_user_out
|
from app.schemas.user import TokenResponse, UserOut, build_user_out
|
||||||
|
from app.services.email_service import (
|
||||||
|
send_account_activated_email,
|
||||||
|
send_email_verification_code_email,
|
||||||
|
)
|
||||||
|
from app.services.email_verification_service import (
|
||||||
|
ACTIVATION_EMAIL_PURPOSE,
|
||||||
|
issue_email_verification_code,
|
||||||
|
verify_email_code,
|
||||||
|
)
|
||||||
from app.services.member_activation_service import get_class_by_invite_code, validate_registration
|
from app.services.member_activation_service import get_class_by_invite_code, validate_registration
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
@ -31,6 +41,30 @@ async def preview_invite_code_class(invite_code: str, db: AsyncSession = Depends
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email-verification-code")
|
||||||
|
async def send_activation_email_code(
|
||||||
|
req: SendEmailCodeRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await db.execute(select(User).where(User.email == req.email))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="该邮箱已注册")
|
||||||
|
|
||||||
|
activation_target = await validate_registration(db, req.invite_code, req.student_id)
|
||||||
|
if activation_target is None:
|
||||||
|
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
|
||||||
|
|
||||||
|
code = await issue_email_verification_code(
|
||||||
|
db,
|
||||||
|
email=req.email,
|
||||||
|
purpose=ACTIVATION_EMAIL_PURPOSE,
|
||||||
|
)
|
||||||
|
sent = await send_email_verification_code_email(req.email, code)
|
||||||
|
if sent is False:
|
||||||
|
raise HTTPException(status_code=500, detail="邮件发送失败,请检查邮件服务配置")
|
||||||
|
return {"message": "验证码已发送,请查收邮箱"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/activate")
|
@router.post("/activate")
|
||||||
async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
# 1. Check if email is already in use
|
# 1. Check if email is already in use
|
||||||
@ -43,6 +77,15 @@ async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_
|
|||||||
if activation_target is None:
|
if activation_target is None:
|
||||||
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
|
raise HTTPException(status_code=400, detail="邀请码或学号无效,或账号已激活")
|
||||||
|
|
||||||
|
is_code_valid = await verify_email_code(
|
||||||
|
db,
|
||||||
|
email=req.email,
|
||||||
|
code=req.email_code,
|
||||||
|
purpose=ACTIVATION_EMAIL_PURPOSE,
|
||||||
|
)
|
||||||
|
if not is_code_valid:
|
||||||
|
raise HTTPException(status_code=400, detail="邮箱验证码无效或已过期")
|
||||||
|
|
||||||
user, class_id = activation_target
|
user, class_id = activation_target
|
||||||
user.email = req.email
|
user.email = req.email
|
||||||
user.password_hash = hash_password(req.password)
|
user.password_hash = hash_password(req.password)
|
||||||
@ -61,6 +104,7 @@ async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_
|
|||||||
|
|
||||||
# 3. Issue token — activation and login in one step
|
# 3. Issue token — activation and login in one step
|
||||||
token = create_access_token({"sub": str(user.id), "role": user.role})
|
token = create_access_token({"sub": str(user.id), "role": user.role})
|
||||||
|
await send_account_activated_email(req.email)
|
||||||
return {
|
return {
|
||||||
"message": "账号激活成功",
|
"message": "账号激活成功",
|
||||||
"token": token,
|
"token": token,
|
||||||
|
|||||||
@ -177,6 +177,21 @@ class ClassMembership(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerificationCode(Base):
|
||||||
|
__tablename__ = "email_verification_codes"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
purpose: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||||
|
consumed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Timeline(Base):
|
class Timeline(Base):
|
||||||
__tablename__ = "timelines"
|
__tablename__ = "timelines"
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,16 @@ class RegisterRequest(BaseModel):
|
|||||||
invite_code: str
|
invite_code: str
|
||||||
student_id: str
|
student_id: str
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
email_code: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailCodeRequest(BaseModel):
|
||||||
|
invite_code: str
|
||||||
|
student_id: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
class ChangePasswordRequest(BaseModel):
|
class ChangePasswordRequest(BaseModel):
|
||||||
old_password: str
|
old_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
|||||||
@ -9,6 +9,137 @@ from app.config import settings
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_email_shell(
|
||||||
|
*,
|
||||||
|
eyebrow: str,
|
||||||
|
title: str,
|
||||||
|
body_html: str,
|
||||||
|
action_label: str | None = None,
|
||||||
|
action_url: str | None = None,
|
||||||
|
summary_html: str | None = None,
|
||||||
|
footer_note: str = "此邮件由系统自动发送,请勿直接回复。",
|
||||||
|
) -> str:
|
||||||
|
action_html = ""
|
||||||
|
if action_label and action_url:
|
||||||
|
action_html = f"""
|
||||||
|
<div style="margin-top:28px;">
|
||||||
|
<a
|
||||||
|
href="{action_url}"
|
||||||
|
style="
|
||||||
|
display:inline-block;
|
||||||
|
padding:12px 22px;
|
||||||
|
border-radius:999px;
|
||||||
|
background:linear-gradient(135deg,#6c1a25 0%,#915435 62%,#d9b68d 130%);
|
||||||
|
color:#fffaf3;
|
||||||
|
text-decoration:none;
|
||||||
|
font-size:14px;
|
||||||
|
font-weight:600;
|
||||||
|
letter-spacing:0.01em;
|
||||||
|
box-shadow:0 14px 32px -18px rgba(83,25,24,0.55);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{action_label}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
summary_block = ""
|
||||||
|
if summary_html:
|
||||||
|
summary_block = f"""
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
margin:18px 0 0;
|
||||||
|
padding:14px 16px;
|
||||||
|
border:1px solid #eadcc8;
|
||||||
|
border-radius:18px;
|
||||||
|
background:#fff4e3;
|
||||||
|
font-size:13px;
|
||||||
|
line-height:1.8;
|
||||||
|
color:#84553c;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{summary_html}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<body style="margin:0;padding:0;background:#f7efe2;">
|
||||||
|
<div style="margin:0;padding:32px 16px;background:linear-gradient(180deg,#f7efe2 0%,#f4ebdf 38%,#f9f5ee 100%);">
|
||||||
|
<div style="max-width:640px;margin:0 auto;">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
overflow:hidden;
|
||||||
|
border:1px solid #e7d3ba;
|
||||||
|
border-radius:28px;
|
||||||
|
background:#fffaf3;
|
||||||
|
box-shadow:0 28px 80px -44px rgba(83,25,24,0.34);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
padding:32px 36px 28px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(115,25,37,0.18), transparent 58%),
|
||||||
|
linear-gradient(135deg, rgba(108,26,37,0.98), rgba(145,84,53,0.92) 58%, rgba(233,206,160,0.88) 140%);
|
||||||
|
color:#fff7ee;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
display:inline-block;
|
||||||
|
padding:7px 12px;
|
||||||
|
border:1px solid rgba(255,245,231,0.28);
|
||||||
|
border-radius:999px;
|
||||||
|
background:rgba(255,250,242,0.08);
|
||||||
|
font-size:11px;
|
||||||
|
letter-spacing:0.24em;
|
||||||
|
text-transform:uppercase;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<h1 style="margin:18px 0 0;font-size:30px;line-height:1.22;font-weight:700;">
|
||||||
|
香港大学中国商业学院
|
||||||
|
</h1>
|
||||||
|
<p style="margin:8px 0 0;font-size:14px;line-height:1.7;color:rgba(255,247,238,0.86);">
|
||||||
|
HKU ICB · 班级信息管理平台
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:32px 36px 14px;">
|
||||||
|
<h2 style="margin:0 0 14px;font-size:24px;line-height:1.35;color:#4e1d1a;">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<div style="font-size:15px;line-height:1.9;color:#775a4a;">
|
||||||
|
{body_html}
|
||||||
|
</div>
|
||||||
|
{summary_block}
|
||||||
|
{action_html}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="padding:18px 36px 30px;">
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
border-top:1px solid #eadcc8;
|
||||||
|
padding-top:16px;
|
||||||
|
font-size:12px;
|
||||||
|
line-height:1.8;
|
||||||
|
color:#9a7b68;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{footer_note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
async def send_email(to: str, subject: str, html_body: str) -> bool:
|
async def send_email(to: str, subject: str, html_body: str) -> bool:
|
||||||
"""Send HTML email via SMTP. Returns True on success."""
|
"""Send HTML email via SMTP. Returns True on success."""
|
||||||
if not settings.smtp_host:
|
if not settings.smtp_host:
|
||||||
@ -36,13 +167,35 @@ async def send_email(to: str, subject: str, html_body: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def send_account_activated_email(member_email: str):
|
async def send_account_activated_email(member_email: str) -> bool:
|
||||||
html = """
|
login_url = f"{settings.frontend_url.rstrip('/')}/login"
|
||||||
<h2>Account Activated</h2>
|
html = build_email_shell(
|
||||||
<p>Your HKU ICB account has been activated successfully.</p>
|
eyebrow="Account Ready",
|
||||||
<p>You can now log in to the platform.</p>
|
title="账号已激活,可以开始使用",
|
||||||
"""
|
body_html="""
|
||||||
await send_email(member_email, "HKU ICB: Account Activated", html)
|
<p style="margin:0 0 12px;">你的 HKU ICB 班级账号已经成功激活。</p>
|
||||||
|
<p style="margin:0;">现在可以使用注册邮箱登录平台,进入班级空间查看公告、排期、资源与成员信息。</p>
|
||||||
|
""",
|
||||||
|
action_label="立即登录",
|
||||||
|
action_url=login_url,
|
||||||
|
)
|
||||||
|
return await send_email(member_email, "HKU ICB:账号激活成功", html)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email_verification_code_email(member_email: str, code: str) -> bool:
|
||||||
|
html = build_email_shell(
|
||||||
|
eyebrow="Email Verification",
|
||||||
|
title="请验证你的邮箱地址",
|
||||||
|
body_html="""
|
||||||
|
<p style="margin:0 0 12px;">你正在激活 HKU ICB 班级账号,请使用下方验证码完成邮箱验证。</p>
|
||||||
|
<p style="margin:0;">验证码 10 分钟内有效;如果不是你本人操作,可以忽略此邮件。</p>
|
||||||
|
""",
|
||||||
|
summary_html=f"""
|
||||||
|
<div style="font-size:12px;letter-spacing:0.18em;text-transform:uppercase;color:#9a7b68;">邮箱验证码</div>
|
||||||
|
<div style="margin-top:8px;font-size:32px;line-height:1.2;font-weight:700;letter-spacing:0.32em;color:#4e1d1a;">{code}</div>
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
return await send_email(member_email, "HKU ICB:邮箱验证码", html)
|
||||||
|
|
||||||
|
|
||||||
async def send_class_notification_email(
|
async def send_class_notification_email(
|
||||||
@ -51,31 +204,19 @@ async def send_class_notification_email(
|
|||||||
title: str,
|
title: str,
|
||||||
body: str,
|
body: str,
|
||||||
action_url: str | None = None,
|
action_url: str | None = None,
|
||||||
|
eyebrow: str = "Class Update",
|
||||||
|
action_label: str | None = "查看详情",
|
||||||
|
summary_html: str | None = None,
|
||||||
):
|
):
|
||||||
"""Send a styled notification email to class members."""
|
"""Send a styled notification email to class members."""
|
||||||
action_html = ""
|
normalized_body = body.replace("\n", "<br />")
|
||||||
if action_url:
|
html = build_email_shell(
|
||||||
action_html = f"""
|
eyebrow=eyebrow,
|
||||||
<div style="margin-top: 20px;">
|
title=title,
|
||||||
<a href="{action_url}" style="display:inline-block;padding:10px 24px;background:#111827;color:#fff;border-radius:6px;text-decoration:none;font-size:14px;">
|
body_html=f"<div>{normalized_body}</div>",
|
||||||
查看详情
|
action_label=action_label if action_url else None,
|
||||||
</a>
|
action_url=action_url,
|
||||||
</div>
|
summary_html=summary_html,
|
||||||
"""
|
)
|
||||||
html = f"""
|
|
||||||
<div style="max-width:600px;margin:0 auto;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#1f2937;">
|
|
||||||
<div style="padding:24px 0;border-bottom:2px solid #111827;">
|
|
||||||
<h1 style="margin:0;font-size:20px;color:#111827;">HKU ICB</h1>
|
|
||||||
</div>
|
|
||||||
<div style="padding:24px 0;">
|
|
||||||
<h2 style="margin:0 0 12px;font-size:18px;">{title}</h2>
|
|
||||||
<div style="font-size:14px;line-height:1.6;color:#4b5563;">{body}</div>
|
|
||||||
{action_html}
|
|
||||||
</div>
|
|
||||||
<div style="padding:16px 0;border-top:1px solid #e5e7eb;font-size:12px;color:#9ca3af;">
|
|
||||||
此邮件由系统自动发送,请勿直接回复。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
for email in emails:
|
for email in emails:
|
||||||
await send_email(email, subject, html)
|
await send_email(email, subject, html)
|
||||||
|
|||||||
107
backend/app/services/email_verification_service.py
Normal file
107
backend/app/services/email_verification_service.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import EmailVerificationCode
|
||||||
|
|
||||||
|
|
||||||
|
ACTIVATION_EMAIL_PURPOSE = "account_activation"
|
||||||
|
EMAIL_CODE_TTL_MINUTES = 10
|
||||||
|
EMAIL_CODE_RESEND_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_code(code: str) -> str:
|
||||||
|
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_email_code() -> str:
|
||||||
|
return f"{secrets.randbelow(1000000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
async def issue_email_verification_code(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
purpose: str = ACTIVATION_EMAIL_PURPOSE,
|
||||||
|
) -> str:
|
||||||
|
normalized_email = email.strip().lower()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == normalized_email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
EmailVerificationCode.consumed_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(EmailVerificationCode.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
latest = latest_result.scalar_one_or_none()
|
||||||
|
if latest and latest.created_at and (now - latest.created_at.replace(tzinfo=timezone.utc)).total_seconds() < EMAIL_CODE_RESEND_SECONDS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail=f"验证码发送过于频繁,请在 {EMAIL_CODE_RESEND_SECONDS} 秒后重试",
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
update(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == normalized_email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
EmailVerificationCode.consumed_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(consumed_at=now)
|
||||||
|
)
|
||||||
|
|
||||||
|
code = generate_email_code()
|
||||||
|
db.add(
|
||||||
|
EmailVerificationCode(
|
||||||
|
email=normalized_email,
|
||||||
|
purpose=purpose,
|
||||||
|
code_hash=_hash_code(code),
|
||||||
|
expires_at=now + timedelta(minutes=EMAIL_CODE_TTL_MINUTES),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_email_code(
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
code: str,
|
||||||
|
purpose: str = ACTIVATION_EMAIL_PURPOSE,
|
||||||
|
) -> bool:
|
||||||
|
normalized_email = email.strip().lower()
|
||||||
|
normalized_code = code.strip()
|
||||||
|
if not normalized_code:
|
||||||
|
return False
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
result = await db.execute(
|
||||||
|
select(EmailVerificationCode)
|
||||||
|
.where(
|
||||||
|
EmailVerificationCode.email == normalized_email,
|
||||||
|
EmailVerificationCode.purpose == purpose,
|
||||||
|
EmailVerificationCode.consumed_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(EmailVerificationCode.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
record = result.scalar_one_or_none()
|
||||||
|
if record is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expires_at = record.expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if expires_at < now or record.code_hash != _hash_code(normalized_code):
|
||||||
|
return False
|
||||||
|
|
||||||
|
record.consumed_at = now
|
||||||
|
await db.commit()
|
||||||
|
return True
|
||||||
@ -10,6 +10,60 @@ from app.services.email_service import send_class_notification_email
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
EMAIL_VISUALS: dict[str, dict[str, str | None]] = {
|
||||||
|
"announcement": {
|
||||||
|
"eyebrow": "Announcement",
|
||||||
|
"action_label": "查看公告",
|
||||||
|
"summary_prefix": "公告摘要",
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"eyebrow": "Class Feed",
|
||||||
|
"action_label": "查看动态",
|
||||||
|
"summary_prefix": "动态摘要",
|
||||||
|
},
|
||||||
|
"vote": {
|
||||||
|
"eyebrow": "Voting Open",
|
||||||
|
"action_label": "立即投票",
|
||||||
|
"summary_prefix": "投票说明",
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"eyebrow": "Schedule Update",
|
||||||
|
"action_label": "查看排期",
|
||||||
|
"summary_prefix": "时间信息",
|
||||||
|
},
|
||||||
|
"assignment": {
|
||||||
|
"eyebrow": "Assignment Posted",
|
||||||
|
"action_label": "查看作业",
|
||||||
|
"summary_prefix": "作业说明",
|
||||||
|
},
|
||||||
|
"resource": {
|
||||||
|
"eyebrow": "Resource Added",
|
||||||
|
"action_label": "查看资源",
|
||||||
|
"summary_prefix": "资源说明",
|
||||||
|
},
|
||||||
|
"fund": {
|
||||||
|
"eyebrow": "Fund Update",
|
||||||
|
"action_label": "查看班费",
|
||||||
|
"summary_prefix": "账务说明",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_meta(type_: str, email_body: str | None) -> tuple[str, str | None, str | None]:
|
||||||
|
visual = EMAIL_VISUALS.get(type_, {})
|
||||||
|
eyebrow = str(visual.get("eyebrow") or "Class Update")
|
||||||
|
action_label = visual.get("action_label")
|
||||||
|
summary_prefix = visual.get("summary_prefix")
|
||||||
|
|
||||||
|
if not email_body:
|
||||||
|
return eyebrow, action_label, None
|
||||||
|
|
||||||
|
summary_html = email_body
|
||||||
|
if summary_prefix:
|
||||||
|
summary_html = f"<strong>{summary_prefix}:</strong>{email_body}"
|
||||||
|
return eyebrow, action_label, summary_html
|
||||||
|
|
||||||
|
|
||||||
async def create_notification(
|
async def create_notification(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@ -72,15 +126,43 @@ async def create_notifications_for_class(
|
|||||||
if email_subject and emails:
|
if email_subject and emails:
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None
|
action_url = f"{settings.frontend_url}{email_action_path}" if email_action_path else None
|
||||||
asyncio.create_task(_safe_send_emails(emails, email_subject, title, email_body or content or "", action_url))
|
eyebrow, action_label, summary_html = _build_email_meta(type, email_body or content or "")
|
||||||
|
asyncio.create_task(
|
||||||
|
_safe_send_emails(
|
||||||
|
emails,
|
||||||
|
email_subject,
|
||||||
|
title,
|
||||||
|
email_body or content or "",
|
||||||
|
action_url,
|
||||||
|
eyebrow,
|
||||||
|
action_label,
|
||||||
|
summary_html,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _safe_send_emails(
|
async def _safe_send_emails(
|
||||||
emails: list[str], subject: str, title: str, body: str, action_url: str | None
|
emails: list[str],
|
||||||
|
subject: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
action_url: str | None,
|
||||||
|
eyebrow: str,
|
||||||
|
action_label: str | None,
|
||||||
|
summary_html: str | None,
|
||||||
):
|
):
|
||||||
"""Fire-and-forget email sending with error logging."""
|
"""Fire-and-forget email sending with error logging."""
|
||||||
try:
|
try:
|
||||||
await send_class_notification_email(emails, subject, title, body, action_url)
|
await send_class_notification_email(
|
||||||
|
emails,
|
||||||
|
subject,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
action_url,
|
||||||
|
eyebrow=eyebrow,
|
||||||
|
action_label=action_label,
|
||||||
|
summary_html=summary_html,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send class notification emails: {e}")
|
logger.error(f"Failed to send class notification emails: {e}")
|
||||||
|
|
||||||
|
|||||||
@ -20,16 +20,37 @@ export default function ActivatePage() {
|
|||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [studentId, setStudentId] = useState("");
|
const [studentId, setStudentId] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
|
const [emailCode, setEmailCode] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [classPreview, setClassPreview] = useState<InviteCodeClassPreview | null>(null);
|
const [classPreview, setClassPreview] = useState<InviteCodeClassPreview | null>(null);
|
||||||
const [classLookupLoading, setClassLookupLoading] = useState(false);
|
const [classLookupLoading, setClassLookupLoading] = useState(false);
|
||||||
const [classLookupError, setClassLookupError] = useState("");
|
const [classLookupError, setClassLookupError] = useState("");
|
||||||
|
const [sendingEmailCode, setSendingEmailCode] = useState(false);
|
||||||
|
const [emailCodeCooldown, setEmailCodeCooldown] = useState(0);
|
||||||
|
const [emailCodeSentTo, setEmailCodeSentTo] = useState("");
|
||||||
|
const [emailCodeNotice, setEmailCodeNotice] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emailCodeCooldown <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
setEmailCodeCooldown((current) => {
|
||||||
|
if (current <= 1) {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return current - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [emailCodeCooldown]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const code = searchParams.get("code")?.trim();
|
const code = searchParams.get("code")?.trim();
|
||||||
if (code) {
|
if (code) {
|
||||||
@ -63,6 +84,52 @@ export default function ActivatePage() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [inviteCode]);
|
}, [inviteCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!emailCodeSentTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (email.trim().toLowerCase() !== emailCodeSentTo) {
|
||||||
|
setEmailCode("");
|
||||||
|
setEmailCodeNotice("");
|
||||||
|
}
|
||||||
|
}, [email, emailCodeSentTo]);
|
||||||
|
|
||||||
|
const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
||||||
|
|
||||||
|
const handleSendEmailCode = async () => {
|
||||||
|
setError("");
|
||||||
|
setEmailCodeNotice("");
|
||||||
|
|
||||||
|
if (!inviteCode.trim()) {
|
||||||
|
setError("请先输入班级邀请码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!studentId.trim()) {
|
||||||
|
setError("请先输入学号");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEmailValid) {
|
||||||
|
setError("请输入正确的邮箱地址");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSendingEmailCode(true);
|
||||||
|
try {
|
||||||
|
await postAPI<{ message: string }>("/api/auth/email-verification-code", {
|
||||||
|
invite_code: inviteCode.trim().toUpperCase(),
|
||||||
|
student_id: studentId.trim(),
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
});
|
||||||
|
setEmailCodeSentTo(email.trim().toLowerCase());
|
||||||
|
setEmailCodeCooldown(60);
|
||||||
|
setEmailCodeNotice("验证码已发送,请前往邮箱查收。");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(getErrorMessage(err, "验证码发送失败"));
|
||||||
|
} finally {
|
||||||
|
setSendingEmailCode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError("");
|
setError("");
|
||||||
@ -75,13 +142,22 @@ export default function ActivatePage() {
|
|||||||
setError("密码至少8位");
|
setError("密码至少8位");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!emailCode.trim()) {
|
||||||
|
setError("请输入邮箱验证码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emailCodeSentTo !== email.trim().toLowerCase()) {
|
||||||
|
setError("邮箱已变更,请重新获取验证码");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await postAPI<LoginResponse>("/api/auth/activate", {
|
const res = await postAPI<LoginResponse>("/api/auth/activate", {
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode.trim().toUpperCase(),
|
||||||
student_id: studentId,
|
student_id: studentId.trim(),
|
||||||
email,
|
email: email.trim().toLowerCase(),
|
||||||
|
email_code: emailCode.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -157,12 +233,39 @@ export default function ActivatePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="shrink-0 sm:min-w-[132px]"
|
||||||
|
disabled={sendingEmailCode || emailCodeCooldown > 0}
|
||||||
|
onClick={handleSendEmailCode}
|
||||||
|
>
|
||||||
|
{sendingEmailCode ? "发送中..." : emailCodeCooldown > 0 ? `${emailCodeCooldown}s 后重发` : "发送验证码"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-[#9a7b68]">
|
||||||
|
验证码将发送到你的邮箱,10 分钟内有效。
|
||||||
|
</p>
|
||||||
|
{emailCodeNotice && (
|
||||||
|
<p className="text-xs text-emerald-700">{emailCodeNotice}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="emailCode">邮箱验证码</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="emailCode"
|
||||||
type="email"
|
placeholder="请输入 6 位验证码"
|
||||||
placeholder="your@email.com"
|
value={emailCode}
|
||||||
value={email}
|
onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user