From 82b786f5134d43844d85959e9799fc6a31c77eee Mon Sep 17 00:00:00 2001 From: aaron <> Date: Mon, 27 Apr 2026 22:36:48 +0800 Subject: [PATCH] 1 --- .../20260427_add_email_verification_codes.py | 59 +++++ backend/app/api/auth.py | 44 ++++ backend/app/db/models.py | 15 ++ backend/app/schemas/auth.py | 7 + backend/app/services/email_service.py | 203 +++++++++++++++--- .../services/email_verification_service.py | 107 +++++++++ backend/app/services/notification_service.py | 88 +++++++- frontend/src/app/activate/page.tsx | 119 +++++++++- 8 files changed, 600 insertions(+), 42 deletions(-) create mode 100644 backend/alembic/versions/20260427_add_email_verification_codes.py create mode 100644 backend/app/services/email_verification_service.py diff --git a/backend/alembic/versions/20260427_add_email_verification_codes.py b/backend/alembic/versions/20260427_add_email_verification_codes.py new file mode 100644 index 0000000..ceaa653 --- /dev/null +++ b/backend/alembic/versions/20260427_add_email_verification_codes.py @@ -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") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 006032c..4f4a3e4 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -12,8 +12,18 @@ from app.schemas.auth import ( InviteCodeClassPreview, LoginRequest, RegisterRequest, + SendEmailCodeRequest, ) 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 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") async def activate_account(req: RegisterRequest, db: AsyncSession = Depends(get_db)): # 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: 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.email = req.email 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 token = create_access_token({"sub": str(user.id), "role": user.role}) + await send_account_activated_email(req.email) return { "message": "账号激活成功", "token": token, diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 1addef3..97e8768 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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): __tablename__ = "timelines" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index df02d63..7c02440 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -10,9 +10,16 @@ class RegisterRequest(BaseModel): invite_code: str student_id: str email: EmailStr + email_code: str password: str +class SendEmailCodeRequest(BaseModel): + invite_code: str + student_id: str + email: EmailStr + + class ChangePasswordRequest(BaseModel): old_password: str new_password: str diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 8d532d8..a21982a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -9,6 +9,137 @@ from app.config import settings 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""" +
+ + {action_label} + +
+ """ + + summary_block = "" + if summary_html: + summary_block = f""" +
+ {summary_html} +
+ """ + + return f""" + + + +
+
+
+
+
+ {eyebrow} +
+

+ 香港大学中国商业学院 +

+

+ HKU ICB · 班级信息管理平台 +

+
+ +
+

+ {title} +

+
+ {body_html} +
+ {summary_block} + {action_html} +
+ +
+
+ {footer_note} +
+
+
+
+
+ + + """ + + async def send_email(to: str, subject: str, html_body: str) -> bool: """Send HTML email via SMTP. Returns True on success.""" if not settings.smtp_host: @@ -36,13 +167,35 @@ async def send_email(to: str, subject: str, html_body: str) -> bool: return False -async def send_account_activated_email(member_email: str): - html = """ -

Account Activated

-

Your HKU ICB account has been activated successfully.

-

You can now log in to the platform.

- """ - await send_email(member_email, "HKU ICB: Account Activated", html) +async def send_account_activated_email(member_email: str) -> bool: + login_url = f"{settings.frontend_url.rstrip('/')}/login" + html = build_email_shell( + eyebrow="Account Ready", + title="账号已激活,可以开始使用", + body_html=""" +

你的 HKU ICB 班级账号已经成功激活。

+

现在可以使用注册邮箱登录平台,进入班级空间查看公告、排期、资源与成员信息。

+ """, + 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=""" +

你正在激活 HKU ICB 班级账号,请使用下方验证码完成邮箱验证。

+

验证码 10 分钟内有效;如果不是你本人操作,可以忽略此邮件。

+ """, + summary_html=f""" +
邮箱验证码
+
{code}
+ """, + ) + return await send_email(member_email, "HKU ICB:邮箱验证码", html) async def send_class_notification_email( @@ -51,31 +204,19 @@ async def send_class_notification_email( title: str, body: str, 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.""" - action_html = "" - if action_url: - action_html = f""" -
- - 查看详情 - -
- """ - html = f""" -
-
-

HKU ICB

-
-
-

{title}

-
{body}
- {action_html} -
-
- 此邮件由系统自动发送,请勿直接回复。 -
-
- """ + normalized_body = body.replace("\n", "
") + html = build_email_shell( + eyebrow=eyebrow, + title=title, + body_html=f"
{normalized_body}
", + action_label=action_label if action_url else None, + action_url=action_url, + summary_html=summary_html, + ) for email in emails: await send_email(email, subject, html) diff --git a/backend/app/services/email_verification_service.py b/backend/app/services/email_verification_service.py new file mode 100644 index 0000000..1ce7af1 --- /dev/null +++ b/backend/app/services/email_verification_service.py @@ -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 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index b54d29c..c480200 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -10,6 +10,60 @@ from app.services.email_service import send_class_notification_email 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"{summary_prefix}:{email_body}" + return eyebrow, action_label, summary_html + + async def create_notification( db: AsyncSession, user_id: int, @@ -72,15 +126,43 @@ async def create_notifications_for_class( if email_subject and emails: from app.config import settings 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( - 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.""" 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: logger.error(f"Failed to send class notification emails: {e}") diff --git a/frontend/src/app/activate/page.tsx b/frontend/src/app/activate/page.tsx index b456032..09b276b 100644 --- a/frontend/src/app/activate/page.tsx +++ b/frontend/src/app/activate/page.tsx @@ -20,16 +20,37 @@ export default function ActivatePage() { const [inviteCode, setInviteCode] = useState(""); const [studentId, setStudentId] = useState(""); const [email, setEmail] = useState(""); + const [emailCode, setEmailCode] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [classPreview, setClassPreview] = useState(null); const [classLookupLoading, setClassLookupLoading] = useState(false); 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 [loading, setLoading] = useState(false); const router = useRouter(); 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(() => { const code = searchParams.get("code")?.trim(); if (code) { @@ -63,6 +84,52 @@ export default function ActivatePage() { return () => window.clearTimeout(timer); }, [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) => { e.preventDefault(); setError(""); @@ -75,13 +142,22 @@ export default function ActivatePage() { setError("密码至少8位"); return; } + if (!emailCode.trim()) { + setError("请输入邮箱验证码"); + return; + } + if (emailCodeSentTo !== email.trim().toLowerCase()) { + setError("邮箱已变更,请重新获取验证码"); + return; + } setLoading(true); try { const res = await postAPI("/api/auth/activate", { - invite_code: inviteCode, - student_id: studentId, - email, + invite_code: inviteCode.trim().toUpperCase(), + student_id: studentId.trim(), + email: email.trim().toLowerCase(), + email_code: emailCode.trim(), password, }); @@ -157,12 +233,39 @@ export default function ActivatePage() {
+
+ setEmail(e.target.value)} + required + /> + +
+

+ 验证码将发送到你的邮箱,10 分钟内有效。 +

+ {emailCodeNotice && ( +

{emailCodeNotice}

+ )} +
+
+ setEmail(e.target.value)} + id="emailCode" + placeholder="请输入 6 位验证码" + value={emailCode} + onChange={(e) => setEmailCode(e.target.value.replace(/\D/g, "").slice(0, 6))} required />