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"""
+
+ """
+
+ summary_block = ""
+ if summary_html:
+ summary_block = f"""
+
+ {summary_html}
+
+ """
+
+ return f"""
+
+
+
+
+
+
+
+
+ {eyebrow}
+
+
+ 香港大学中国商业学院
+
+
+ HKU ICB · 班级信息管理平台
+
+
+
+
+
+ {title}
+
+
+ {body_html}
+
+ {summary_block}
+ {action_html}
+
+
+
+
+
+
+
+
+ """
+
+
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
/>