From 6afc5817a784bfdc693d7158fe525daf933b3307 Mon Sep 17 00:00:00 2001
From: Cccc_
Date: Wed, 6 May 2026 20:57:54 +0800
Subject: [PATCH] feat(auth): require verified email for approval
---
apps/backend/api/admin.py | 19 +-
apps/backend/api/users.py | 73 ++++-
.../email_notification_settings.py | 2 +
.../user_email_verification.py | 60 ++++
apps/backend/migrations.py | 8 +
apps/backend/models/email_settings.py | 6 +
apps/backend/models/user.py | 14 +
apps/backend/schemas/__init__.py | 6 +
apps/backend/schemas/email_settings.py | 6 +
apps/backend/schemas/user.py | 25 ++
apps/backend/services/admin_service.py | 36 ++-
apps/backend/services/auth_service.py | 5 +-
apps/backend/services/email_service.py | 13 +
.../services/email_settings_service.py | 42 +++
apps/backend/services/user_service.py | 93 +++++-
apps/frontend/src/api/index.ts | 15 +-
apps/frontend/src/api/types.ts | 16 ++
apps/frontend/src/app/auth.ts | 2 +
.../src/views/PendingApprovalView.vue | 111 +++++++-
.../views/admin/AdminEmailSettingsView.vue | 40 +++
.../src/views/admin/AdminUsersView.vue | 23 +-
tests/test_backend_auto_migrations.py | 41 +++
tests/test_email_notification_settings.py | 17 ++
tests/test_email_notification_templates.py | 8 +-
tests/test_frontend_architecture.py | 22 ++
tests/test_user_email_verification.py | 269 ++++++++++++++++++
26 files changed, 944 insertions(+), 28 deletions(-)
create mode 100644 apps/backend/migration_steps/user_email_verification.py
create mode 100644 tests/test_user_email_verification.py
diff --git a/apps/backend/api/admin.py b/apps/backend/api/admin.py
index bcbe261..3d36c09 100644
--- a/apps/backend/api/admin.py
+++ b/apps/backend/api/admin.py
@@ -1,6 +1,6 @@
from typing import List
import logging
-from fastapi import APIRouter, Depends, HTTPException, Query, status
+from fastapi import APIRouter, Body, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
@@ -10,7 +10,7 @@ from backend.schemas.email_settings import (
EmailNotificationSettingsResponse,
EmailNotificationSettingsUpdate,
)
-from backend.schemas.user import UserResponse
+from backend.schemas.user import AdminApprovalResponse, UserResponse
from backend.services.check_in_service import CheckInService
from backend.services.admin_service import AdminService
from backend.services.email_settings_service import EmailSettingsService
@@ -322,9 +322,14 @@ async def get_pending_users(
)
-@router.post("/users/{user_id}/approve", response_model=dict, summary="审批通过用户")
+@router.post(
+ "/users/{user_id}/approve",
+ response_model=AdminApprovalResponse,
+ summary="审批通过用户",
+)
async def approve_user(
user_id: int,
+ payload: dict = Body(default_factory=dict),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
@@ -332,9 +337,15 @@ async def approve_user(
审批通过指定用户(需要管理员权限)
"""
try:
- result = AdminService.approve_user(user_id, db)
+ result = AdminService.approve_user(
+ user_id,
+ db,
+ allow_unverified_email=bool(payload.get("allow_unverified_email", False)),
+ )
if not result["success"]:
+ if result.get("requires_override"):
+ return result
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=result["message"])
return result
diff --git a/apps/backend/api/users.py b/apps/backend/api/users.py
index b74c7ff..a6df0ca 100644
--- a/apps/backend/api/users.py
+++ b/apps/backend/api/users.py
@@ -4,15 +4,19 @@ from sqlalchemy.orm import Session
from backend.models import get_db, User
from backend.schemas.user import (
+ AdminApprovalResponse,
UserCreate,
UserUpdate,
UserResponse,
TokenStatus,
UserUpdateProfile,
+ UserEmailUpdate,
+ UserEmailVerify,
)
from backend.schemas.task import TaskResponse
from backend.services.user_service import UserService
from backend.services.task_service import TaskService
+from backend.services.admin_service import AdminService
from backend.dependencies import get_current_user, get_current_admin_user
from backend.exceptions import (
AuthorizationError,
@@ -69,6 +73,8 @@ async def get_current_user_info(current_user: User = Depends(get_current_user)):
"is_approved": current_user.is_approved,
"jwt_exp": current_user.jwt_exp,
"email": current_user.email,
+ "email_verified": current_user.email_verified,
+ "email_verified_at": current_user.email_verified_at,
"has_password": bool(current_user.password_hash),
"created_at": current_user.created_at,
"updated_at": current_user.updated_at,
@@ -85,10 +91,57 @@ async def get_user_status(current_user: User = Depends(get_current_user)):
"user_id": current_user.id,
"alias": current_user.alias,
"is_approved": current_user.is_approved,
+ "email": current_user.email,
+ "email_verified": current_user.email_verified,
+ "email_verified_at": current_user.email_verified_at.isoformat()
+ if current_user.email_verified_at
+ else None,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
}
+@router.put("/me/email", response_model=UserResponse, summary="设置邮箱并发送验证码")
+async def set_current_user_email(
+ email_data: UserEmailUpdate,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """
+ 设置当前用户邮箱并发送验证码(不要求账户已审批)。
+ """
+ try:
+ return UserService.set_email_for_verification(current_user.id, str(email_data.email), db)
+ except ValueError as e:
+ raise ValidationError(str(e))
+ except EXPECTED_API_EXCEPTIONS:
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"设置邮箱失败: {str(e)}"
+ )
+
+
+@router.post("/me/email/verify", response_model=UserResponse, summary="验证当前用户邮箱")
+async def verify_current_user_email(
+ verify_data: UserEmailVerify,
+ db: Session = Depends(get_db),
+ current_user: User = Depends(get_current_user),
+):
+ """
+ 校验当前用户邮箱验证码(不要求账户已审批)。
+ """
+ try:
+ return UserService.verify_email_code(current_user.id, verify_data.code, db)
+ except ValueError as e:
+ raise ValidationError(str(e))
+ except EXPECTED_API_EXCEPTIONS:
+ raise
+ except Exception as e:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"验证邮箱失败: {str(e)}"
+ )
+
+
@router.put("/me/profile", response_model=UserResponse, summary="更新个人信息")
async def update_current_user_profile(
profile_data: UserUpdateProfile,
@@ -211,7 +264,11 @@ async def get_user(
return user
-@router.put("/{user_id}", response_model=UserResponse, summary="更新用户信息")
+@router.put(
+ "/{user_id}",
+ response_model=UserResponse | AdminApprovalResponse,
+ summary="更新用户信息",
+)
async def update_user(
user_id: int,
user_data: UserUpdate,
@@ -241,6 +298,19 @@ async def update_user(
# 保存更新前的审批状态 (先读取后转换为 Python bool)
old_approved_value = old_user.is_approved
was_approved_before = True if old_approved_value else False
+ is_admin = current_user.role == "admin"
+ update_data = user_data.model_dump(exclude_unset=True)
+ will_approve = (
+ is_admin and (not was_approved_before) and update_data.get("is_approved") is True
+ )
+ if will_approve:
+ approval_warning = AdminService.approval_warning_for_user(
+ old_user,
+ allow_unverified_email=user_data.allow_unverified_email,
+ next_email=str(update_data["email"]) if "email" in update_data else None,
+ )
+ if approval_warning and approval_warning.get("requires_override"):
+ return approval_warning
# 更新用户信息
user = UserService.update_user(user_id, user_data, db)
@@ -249,7 +319,6 @@ async def update_user(
new_approved_value = user.is_approved
is_approved_now = True if new_approved_value else False
- is_admin = current_user.role == "admin"
needs_notification = is_admin and (not was_approved_before) and is_approved_now
if needs_notification:
diff --git a/apps/backend/migration_steps/email_notification_settings.py b/apps/backend/migration_steps/email_notification_settings.py
index 6c677f7..ef13352 100644
--- a/apps/backend/migration_steps/email_notification_settings.py
+++ b/apps/backend/migration_steps/email_notification_settings.py
@@ -17,6 +17,8 @@ def apply(conn: Connection) -> None:
smtp_use_ssl BOOLEAN NOT NULL DEFAULT 1,
notify_token_expiring BOOLEAN NOT NULL DEFAULT 1,
notify_check_in_success BOOLEAN NOT NULL DEFAULT 1,
+ require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1,
+ warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME
)
diff --git a/apps/backend/migration_steps/user_email_verification.py b/apps/backend/migration_steps/user_email_verification.py
new file mode 100644
index 0000000..53b83b4
--- /dev/null
+++ b/apps/backend/migration_steps/user_email_verification.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+from sqlalchemy import text
+from sqlalchemy.engine import Connection
+
+
+def _table_columns(conn: Connection, table_name: str) -> set[str]:
+ rows = conn.execute(text(f"PRAGMA table_info({table_name})")).fetchall()
+ return {str(row[1]) for row in rows}
+
+
+def _add_column_if_missing(
+ conn: Connection, table_name: str, columns: set[str], column_name: str, ddl: str
+) -> set[str]:
+ if column_name not in columns:
+ conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {ddl}"))
+ conn.commit()
+ return _table_columns(conn, table_name)
+ return columns
+
+
+def apply(conn: Connection) -> None:
+ user_columns = _table_columns(conn, "users")
+ user_columns = _add_column_if_missing(
+ conn,
+ "users",
+ user_columns,
+ "email_verified_at",
+ "email_verified_at DATETIME",
+ )
+ user_columns = _add_column_if_missing(
+ conn,
+ "users",
+ user_columns,
+ "email_verification_code_hash",
+ "email_verification_code_hash VARCHAR(200)",
+ )
+ _add_column_if_missing(
+ conn,
+ "users",
+ user_columns,
+ "email_verification_expires_at",
+ "email_verification_expires_at DATETIME",
+ )
+
+ settings_columns = _table_columns(conn, "email_notification_settings")
+ settings_columns = _add_column_if_missing(
+ conn,
+ "email_notification_settings",
+ settings_columns,
+ "require_admin_approval_for_registration",
+ "require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1",
+ )
+ _add_column_if_missing(
+ conn,
+ "email_notification_settings",
+ settings_columns,
+ "warn_unverified_email_before_approval",
+ "warn_unverified_email_before_approval BOOLEAN NOT NULL DEFAULT 1",
+ )
diff --git a/apps/backend/migrations.py b/apps/backend/migrations.py
index 8bfc265..82b7c6e 100644
--- a/apps/backend/migrations.py
+++ b/apps/backend/migrations.py
@@ -13,6 +13,9 @@ from backend.migration_steps.email_notification_settings import (
apply as apply_email_notification_settings,
)
from backend.migration_steps.task_thread_id import apply as apply_task_thread_id
+from backend.migration_steps.user_email_verification import (
+ apply as apply_user_email_verification,
+)
from backend.models.database import engine as default_engine
logger = logging.getLogger(__name__)
@@ -94,6 +97,11 @@ MIGRATIONS: tuple[Migration, ...] = (
description="Add admin-managed email notification settings.",
apply=apply_email_notification_settings,
),
+ Migration(
+ id="2026050601_add_user_email_verification",
+ description="Add user email verification fields and registration approval policy flags.",
+ apply=apply_user_email_verification,
+ ),
)
diff --git a/apps/backend/models/email_settings.py b/apps/backend/models/email_settings.py
index 8f0e3bc..9fd4f5d 100644
--- a/apps/backend/models/email_settings.py
+++ b/apps/backend/models/email_settings.py
@@ -22,6 +22,12 @@ class EmailNotificationSettings(Base):
smtp_use_ssl: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_token_expiring: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notify_check_in_success: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ require_admin_approval_for_registration: Mapped[bool] = mapped_column(
+ Boolean, default=True, nullable=False
+ )
+ warn_unverified_email_before_approval: Mapped[bool] = mapped_column(
+ Boolean, default=True, nullable=False
+ )
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
)
diff --git a/apps/backend/models/user.py b/apps/backend/models/user.py
index b6d36e2..70341df 100644
--- a/apps/backend/models/user.py
+++ b/apps/backend/models/user.py
@@ -30,6 +30,15 @@ class User(Base):
email: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="用户邮箱(用于接收通知)"
)
+ email_verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, comment="邮箱验证时间"
+ )
+ email_verification_code_hash: Mapped[str | None] = mapped_column(
+ String(200), nullable=True, comment="邮箱验证码哈希"
+ )
+ email_verification_expires_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True, comment="邮箱验证码过期时间"
+ )
password_hash: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="密码哈希(bcrypt加密)"
)
@@ -91,3 +100,8 @@ class User(Base):
def is_admin(self) -> bool:
"""判断是否为管理员"""
return self.role == "admin"
+
+ @property
+ def email_verified(self) -> bool:
+ """判断当前邮箱是否已验证"""
+ return bool(self.email and self.email_verified_at)
diff --git a/apps/backend/schemas/__init__.py b/apps/backend/schemas/__init__.py
index 837b8a3..c91f749 100644
--- a/apps/backend/schemas/__init__.py
+++ b/apps/backend/schemas/__init__.py
@@ -5,6 +5,9 @@ from backend.schemas.user import (
UserResponse,
UserWithToken,
TokenStatus,
+ UserEmailUpdate,
+ UserEmailVerify,
+ AdminApprovalResponse,
)
from backend.schemas.auth import (
QRCodeRequest,
@@ -49,6 +52,9 @@ __all__ = [
"UserResponse",
"UserWithToken",
"TokenStatus",
+ "UserEmailUpdate",
+ "UserEmailVerify",
+ "AdminApprovalResponse",
"QRCodeRequest",
"QRCodeResponse",
"QRCodeStatusResponse",
diff --git a/apps/backend/schemas/email_settings.py b/apps/backend/schemas/email_settings.py
index 56baaca..e3263ea 100644
--- a/apps/backend/schemas/email_settings.py
+++ b/apps/backend/schemas/email_settings.py
@@ -13,6 +13,12 @@ class EmailNotificationSettingsBase(BaseModel):
smtp_use_ssl: bool = Field(True, description="是否使用 SMTP SSL")
notify_token_expiring: bool = Field(True, description="是否通知 Token 即将过期")
notify_check_in_success: bool = Field(True, description="是否通知打卡成功")
+ require_admin_approval_for_registration: bool = Field(
+ True, description="新注册是否需要管理员审批"
+ )
+ warn_unverified_email_before_approval: bool = Field(
+ True, description="审批未验证邮箱用户时是否警告"
+ )
@field_validator("smtp_server", "smtp_sender_email", mode="before")
@classmethod
diff --git a/apps/backend/schemas/user.py b/apps/backend/schemas/user.py
index 5d211d7..c0f7864 100644
--- a/apps/backend/schemas/user.py
+++ b/apps/backend/schemas/user.py
@@ -29,6 +29,7 @@ class UserUpdate(BaseModel):
None, min_length=6, description="新密码(可选,留空表示不修改)"
)
reset_password: Optional[bool] = Field(False, description="是否清空密码")
+ allow_unverified_email: bool = Field(False, description="是否允许审批未验证邮箱用户")
class UserUpdateProfile(BaseModel):
@@ -42,6 +43,28 @@ class UserUpdateProfile(BaseModel):
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
+class UserEmailUpdate(BaseModel):
+ """用户设置邮箱并请求验证码 Schema"""
+
+ email: EmailStr = Field(..., description="邮箱地址")
+
+
+class UserEmailVerify(BaseModel):
+ """用户邮箱验证码校验 Schema"""
+
+ code: str = Field(..., min_length=4, max_length=12, description="邮箱验证码")
+
+
+class AdminApprovalResponse(BaseModel):
+ """管理员审批响应 Schema"""
+
+ success: bool
+ message: str
+ user_id: Optional[int] = None
+ requires_override: bool = False
+ warning_code: Optional[str] = None
+
+
class UserResponse(BaseModel):
"""用户响应 Schema"""
@@ -53,6 +76,8 @@ class UserResponse(BaseModel):
is_approved: bool
jwt_exp: str
email: Optional[EmailStr] = None
+ email_verified: bool = False
+ email_verified_at: Optional[datetime] = None
has_password: bool = False # 是否已设置密码
created_at: datetime
updated_at: Optional[datetime] = None
diff --git a/apps/backend/services/admin_service.py b/apps/backend/services/admin_service.py
index 5842124..036b0bc 100644
--- a/apps/backend/services/admin_service.py
+++ b/apps/backend/services/admin_service.py
@@ -11,6 +11,29 @@ logger = logging.getLogger(__name__)
class AdminService:
"""管理员服务"""
+ @staticmethod
+ def approval_warning_for_user(
+ user: User, allow_unverified_email: bool = False, next_email: str | None = None
+ ) -> Dict[str, Any] | None:
+ from backend.services.email_settings_service import EmailSettingsService
+
+ email_changed = next_email is not None and next_email != user.email
+ has_verified_email = bool(user.email and user.email_verified_at and not email_changed)
+ should_warn = (
+ EmailSettingsService.should_warn_unverified_email_before_approval()
+ and not has_verified_email
+ )
+ if should_warn and not allow_unverified_email:
+ return {
+ "success": False,
+ "message": "用户邮箱未验证,确认后仍可继续审批",
+ "requires_override": True,
+ "warning_code": "UNVERIFIED_EMAIL",
+ }
+ if should_warn:
+ return {"warning_code": "UNVERIFIED_EMAIL"}
+ return None
+
@staticmethod
def get_pending_users(db: Session) -> List[User]:
"""获取待审批用户列表"""
@@ -24,7 +47,9 @@ class AdminService:
return users
@staticmethod
- def approve_user(user_id: int, db: Session) -> Dict[str, Any]:
+ def approve_user(
+ user_id: int, db: Session, allow_unverified_email: bool = False
+ ) -> Dict[str, Any]:
"""审批通过用户"""
user = db.query(User).filter(User.id == user_id).first()
@@ -34,13 +59,20 @@ class AdminService:
if user.is_approved:
return {"success": False, "message": "用户已经通过审批"}
+ warning = AdminService.approval_warning_for_user(user, allow_unverified_email)
+ if warning and warning.get("requires_override"):
+ return warning
+
user.is_approved = True
user.updated_at = datetime.now()
db.commit()
logger.info(f"管理员审批通过用户: {user.alias} (ID: {user.id})")
- return {"success": True, "message": "审批成功", "user_id": user.id}
+ result = {"success": True, "message": "审批成功", "user_id": user.id}
+ if warning and warning.get("warning_code"):
+ result["warning_code"] = warning["warning_code"]
+ return result
@staticmethod
def reject_user(user_id: int, db: Session) -> Dict[str, Any]:
diff --git a/apps/backend/services/auth_service.py b/apps/backend/services/auth_service.py
index 80207f4..aa60517 100644
--- a/apps/backend/services/auth_service.py
+++ b/apps/backend/services/auth_service.py
@@ -226,13 +226,16 @@ class AuthService:
return {"status": "error", "message": "注册失败:用户名已被占用,请更换用户名"}
# 创建新用户(待审批状态)
+ from backend.services.email_settings_service import EmailSettingsService
+
+ requires_approval = EmailSettingsService.is_registration_approval_required()
new_user = User(
jwt_sub=jwt_sub,
alias=alias,
authorization=pure_token, # 存储清理后的 token
jwt_exp=jwt_exp,
role="user",
- is_approved=False, # 待审批
+ is_approved=not requires_approval,
)
db.add(new_user)
diff --git a/apps/backend/services/email_service.py b/apps/backend/services/email_service.py
index 5c51b13..8de9713 100644
--- a/apps/backend/services/email_service.py
+++ b/apps/backend/services/email_service.py
@@ -159,6 +159,16 @@ class EmailService:
return EmailService.send_email(admin_emails, subject, body_html)
+ @staticmethod
+ def send_email_verification_code(to_email: str, alias: str, code: str) -> bool:
+ subject = f"【接龙自动打卡系统】邮箱验证码 - {alias}"
+ body_html = (
+ "您正在验证接龙自动打卡系统账号邮箱。
"
+ f"验证码:{_email_text(code)}
"
+ "验证码 10 分钟内有效。如非本人操作,请忽略本邮件。
"
+ )
+ return EmailService.send_email([to_email], subject, body_html)
+
@staticmethod
def notify_user_approved(user: User) -> bool:
"""
@@ -174,6 +184,9 @@ class EmailService:
if user_email is None:
logger.info(f"用户 {user.alias} 未设置邮箱,跳过审批通知")
return False
+ if not user.email_verified_at:
+ logger.info(f"用户 {user.alias} 邮箱未验证,跳过审批通知")
+ return False
subject = f"【接龙自动打卡系统】账户审批通过 - {user.alias}"
body_html = render_email_template(
diff --git a/apps/backend/services/email_settings_service.py b/apps/backend/services/email_settings_service.py
index fb5bf76..4249bfc 100644
--- a/apps/backend/services/email_settings_service.py
+++ b/apps/backend/services/email_settings_service.py
@@ -27,6 +27,8 @@ class EmailSettingsSnapshot:
smtp_use_ssl: bool
notify_token_expiring: bool
notify_check_in_success: bool
+ require_admin_approval_for_registration: bool
+ warn_unverified_email_before_approval: bool
has_smtp_sender_password: bool
created_at: datetime | None = None
updated_at: datetime | None = None
@@ -46,6 +48,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
+ require_admin_approval_for_registration=True,
+ warn_unverified_email_before_approval=True,
)
@staticmethod
@@ -60,6 +64,8 @@ class EmailSettingsService:
smtp_use_ssl=settings.SMTP_USE_SSL,
notify_token_expiring=True,
notify_check_in_success=True,
+ require_admin_approval_for_registration=True,
+ warn_unverified_email_before_approval=True,
has_smtp_sender_password=bool(password),
created_at=None,
updated_at=None,
@@ -93,6 +99,10 @@ class EmailSettingsService:
smtp_use_ssl=bool(row.smtp_use_ssl),
notify_token_expiring=bool(row.notify_token_expiring),
notify_check_in_success=bool(row.notify_check_in_success),
+ require_admin_approval_for_registration=bool(
+ row.require_admin_approval_for_registration
+ ),
+ warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval),
has_smtp_sender_password=bool(password),
created_at=row.created_at,
updated_at=row.updated_at,
@@ -108,6 +118,8 @@ class EmailSettingsService:
smtp_use_ssl=snapshot.smtp_use_ssl,
notify_token_expiring=snapshot.notify_token_expiring,
notify_check_in_success=snapshot.notify_check_in_success,
+ require_admin_approval_for_registration=snapshot.require_admin_approval_for_registration,
+ warn_unverified_email_before_approval=snapshot.warn_unverified_email_before_approval,
has_smtp_sender_password=snapshot.has_smtp_sender_password,
created_at=snapshot.created_at,
updated_at=snapshot.updated_at,
@@ -132,6 +144,10 @@ class EmailSettingsService:
row.smtp_use_ssl = payload.smtp_use_ssl
row.notify_token_expiring = payload.notify_token_expiring
row.notify_check_in_success = payload.notify_check_in_success
+ row.require_admin_approval_for_registration = (
+ payload.require_admin_approval_for_registration
+ )
+ row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval
if payload.clear_smtp_sender_password:
row.smtp_sender_password = ""
@@ -192,3 +208,29 @@ class EmailSettingsService:
return EmailSettingsService._default_snapshot().notify_check_in_success
finally:
db.close()
+
+ @staticmethod
+ def is_registration_approval_required() -> bool:
+ db = SessionLocal()
+ try:
+ try:
+ return EmailSettingsService.get_snapshot(db).require_admin_approval_for_registration
+ except SQLAlchemyError:
+ return (
+ EmailSettingsService._default_snapshot().require_admin_approval_for_registration
+ )
+ finally:
+ db.close()
+
+ @staticmethod
+ def should_warn_unverified_email_before_approval() -> bool:
+ db = SessionLocal()
+ try:
+ try:
+ return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval
+ except SQLAlchemyError:
+ return (
+ EmailSettingsService._default_snapshot().warn_unverified_email_before_approval
+ )
+ finally:
+ db.close()
diff --git a/apps/backend/services/user_service.py b/apps/backend/services/user_service.py
index de4c12f..4367c75 100644
--- a/apps/backend/services/user_service.py
+++ b/apps/backend/services/user_service.py
@@ -1,8 +1,12 @@
import logging
+import secrets
+from datetime import datetime, timedelta, timezone
from typing import List, Optional
-from datetime import datetime
-from sqlalchemy.orm import Session
+
+import bcrypt
+from email_validator import validate_email, EmailNotValidError
from sqlalchemy import or_
+from sqlalchemy.orm import Session
from backend.models import User
from backend.schemas.user import UserCreate, UserUpdate, UserUpdateProfile
@@ -26,6 +30,19 @@ def escape_like_pattern(text: str) -> str:
class UserService:
"""用户服务"""
+ @staticmethod
+ def _clear_email_verification(user: User) -> None:
+ user.email_verified_at = None
+ user.email_verification_code_hash = None
+ user.email_verification_expires_at = None
+
+ @staticmethod
+ def _normalize_email(email: str) -> str:
+ try:
+ return str(validate_email(email.strip(), check_deliverability=False).normalized)
+ except EmailNotValidError as exc:
+ raise ValueError("邮箱格式无效") from exc
+
@staticmethod
def create_user(user_data: UserCreate, db: Session) -> User:
"""
@@ -47,7 +64,7 @@ class UserService:
user = User(
jwt_sub=None, # NULL 表示未绑定 QQ
alias=user_data.alias,
- email=user_data.email,
+ email=str(user_data.email) if user_data.email is not None else None,
role=user_data.role or "user",
is_approved=user_data.is_approved
if user_data.is_approved is not None
@@ -192,7 +209,14 @@ class UserService:
logger.info(f"管理员修改用户 {user.alias} (ID: {user_id}) 的密码")
# 更新其他字段(排除密码相关字段)
- excluded_fields = {"password", "reset_password"}
+ excluded_fields = {"password", "reset_password", "allow_unverified_email"}
+ if "email" in update_data:
+ next_email = update_data["email"]
+ next_email = str(next_email) if next_email is not None else None
+ if next_email != user.email:
+ UserService._clear_email_verification(user)
+ update_data["email"] = next_email
+
for key, value in update_data.items():
if key not in excluded_fields:
setattr(user, key, value)
@@ -235,7 +259,10 @@ class UserService:
# 更新邮箱
if "email" in update_data:
- user.email = update_data["email"]
+ next_email = str(update_data["email"]) if update_data["email"] is not None else None
+ if next_email != user.email:
+ UserService._clear_email_verification(user)
+ user.email = next_email
logger.info(f"用户 ID {user_id} 邮箱更新: {user.email}")
# 更新密码
@@ -262,6 +289,62 @@ class UserService:
logger.info(f"✅ 更新用户个人信息成功: {user.alias} (ID: {user.id})")
return user
+ @staticmethod
+ def set_email_for_verification(user_id: int, email: str, db: Session) -> User:
+ from backend.services.email_service import EmailService
+
+ user = UserService.get_user_by_id(user_id, db)
+ if not user:
+ raise ValueError(f"用户 ID {user_id} 不存在")
+
+ normalized_email = UserService._normalize_email(email)
+ if normalized_email != user.email:
+ user.email = normalized_email
+ UserService._clear_email_verification(user)
+
+ code = f"{secrets.randbelow(1_000_000):06d}"
+ code_hash = bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+ user.email_verification_code_hash = code_hash
+ user.email_verification_expires_at = datetime.now(timezone.utc) + timedelta(minutes=10)
+ user.updated_at = datetime.now(timezone.utc)
+
+ sent = EmailService.send_email_verification_code(normalized_email, user.alias, code)
+ if not sent:
+ db.rollback()
+ raise ValueError("验证码邮件发送失败,请检查邮件配置后重试")
+
+ db.commit()
+ db.refresh(user)
+ logger.info("用户 ID %s 已请求邮箱验证码: %s", user_id, normalized_email)
+ return user
+
+ @staticmethod
+ def verify_email_code(user_id: int, code: str, db: Session) -> User:
+ user = UserService.get_user_by_id(user_id, db)
+ if not user:
+ raise ValueError(f"用户 ID {user_id} 不存在")
+
+ code_hash = user.email_verification_code_hash
+ expires_at = user.email_verification_expires_at
+ now = datetime.now(timezone.utc)
+ if expires_at and expires_at.tzinfo is None:
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
+
+ if not code_hash or not expires_at or expires_at <= now:
+ raise ValueError("验证码无效或已过期")
+
+ if not bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("utf-8")):
+ raise ValueError("验证码无效或已过期")
+
+ user.email_verified_at = now
+ user.email_verification_code_hash = None
+ user.email_verification_expires_at = None
+ user.updated_at = now
+ db.commit()
+ db.refresh(user)
+ logger.info("用户 ID %s 邮箱验证成功: %s", user_id, user.email)
+ return user
+
@staticmethod
def delete_user(user_id: int, db: Session) -> bool:
"""
diff --git a/apps/frontend/src/api/index.ts b/apps/frontend/src/api/index.ts
index 7e88216..f3ac5a6 100644
--- a/apps/frontend/src/api/index.ts
+++ b/apps/frontend/src/api/index.ts
@@ -1,5 +1,6 @@
import { apiClient } from './client'
import type {
+ AdminApprovalResponse,
AdminStats,
CheckInRecord,
CheckInRecordStatus,
@@ -45,6 +46,8 @@ export const userApi = {
me: () => apiClient.get('/api/users/me'),
status: () => apiClient.get('/api/users/me/status'),
tokenStatus: () => apiClient.get('/api/users/me/token_status'),
+ setEmail: (email: string) => apiClient.put('/api/users/me/email', { email }),
+ verifyEmail: (code: string) => apiClient.post('/api/users/me/email/verify', { code }),
updateProfile: (payload: {
alias?: string
email?: string
@@ -56,8 +59,12 @@ export const userApi = {
apiClient.post('/api/users', payload),
update: (
userId: number,
- payload: Partial & { password?: string; reset_password?: boolean },
- ) => apiClient.put(`/api/users/${userId}`, payload),
+ payload: Partial & {
+ password?: string
+ reset_password?: boolean
+ allow_unverified_email?: boolean
+ },
+ ) => apiClient.put(`/api/users/${userId}`, payload),
delete: (userId: number) => apiClient.delete(`/api/users/${userId}`),
}
@@ -104,8 +111,8 @@ export const templateApi = {
export const adminApi = {
pendingUsers: () => apiClient.get('/api/admin/users/pending'),
- approveUser: (userId: number) =>
- apiClient.post<{ success: boolean; message: string }>(`/api/admin/users/${userId}/approve`),
+ approveUser: (userId: number, payload: { allow_unverified_email?: boolean } = {}) =>
+ apiClient.post(`/api/admin/users/${userId}/approve`, payload),
rejectUser: (userId: number) =>
apiClient.delete<{ success: boolean; message: string }>(`/api/admin/users/${userId}/reject`),
stats: () => apiClient.get('/api/admin/stats'),
diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts
index fd625dd..f3f951f 100644
--- a/apps/frontend/src/api/types.ts
+++ b/apps/frontend/src/api/types.ts
@@ -7,6 +7,8 @@ export interface User {
is_approved: boolean
jwt_exp: string
email?: string | null
+ email_verified?: boolean
+ email_verified_at?: string | null
has_password?: boolean
created_at: string
updated_at?: string | null
@@ -35,6 +37,8 @@ export interface AuthUserPayload {
is_approved?: boolean
jwt_exp?: string
email?: string | null
+ email_verified?: boolean
+ email_verified_at?: string | null
has_password?: boolean
created_at?: string
updated_at?: string | null
@@ -210,6 +214,8 @@ export interface EmailNotificationSettings {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
+ require_admin_approval_for_registration: boolean
+ warn_unverified_email_before_approval: boolean
has_smtp_sender_password: boolean
created_at?: string | null
updated_at?: string | null
@@ -222,10 +228,20 @@ export interface EmailNotificationSettingsUpdate {
smtp_use_ssl: boolean
notify_token_expiring: boolean
notify_check_in_success: boolean
+ require_admin_approval_for_registration: boolean
+ warn_unverified_email_before_approval: boolean
smtp_sender_password?: string
clear_smtp_sender_password?: boolean
}
+export interface AdminApprovalResponse {
+ success: boolean
+ message: string
+ user_id?: number
+ requires_override?: boolean
+ warning_code?: string
+}
+
export interface CronValidation {
valid: boolean
message: string
diff --git a/apps/frontend/src/app/auth.ts b/apps/frontend/src/app/auth.ts
index 7a74afd..fd0da1f 100644
--- a/apps/frontend/src/app/auth.ts
+++ b/apps/frontend/src/app/auth.ts
@@ -33,6 +33,8 @@ function userFromLogin(payload: LoginResponse): User | null {
is_approved: raw?.is_approved ?? payload.is_approved ?? false,
jwt_exp: raw?.jwt_exp ?? '',
email: raw?.email ?? null,
+ email_verified: raw?.email_verified ?? false,
+ email_verified_at: raw?.email_verified_at ?? null,
has_password: raw?.has_password,
created_at: raw?.created_at ?? new Date().toISOString(),
updated_at: raw?.updated_at ?? null,
diff --git a/apps/frontend/src/views/PendingApprovalView.vue b/apps/frontend/src/views/PendingApprovalView.vue
index d4d1ac9..9ad8995 100644
--- a/apps/frontend/src/views/PendingApprovalView.vue
+++ b/apps/frontend/src/views/PendingApprovalView.vue
@@ -1,17 +1,26 @@
@@ -39,6 +79,9 @@ async function refresh() {
当前账号
{{ auth.state.user?.alias ?? '未知用户' }} 已完成登录,但还需要管理员审批后才能访问工作台。
+
+ 请填写并验证邮箱。只有邮箱完成验证后账号才会进入正常审批流程;未验证的待审批账号可能会在清理窗口后自动吊销。
+
@@ -52,7 +95,69 @@ async function refresh() {
- 审批状态
- 待审批
+
+
邮箱状态
+
+ {{ emailVerified ? '已验证' : '未验证' }}
+
+
+
+
+
{{ error }}
diff --git a/apps/frontend/src/views/admin/AdminEmailSettingsView.vue b/apps/frontend/src/views/admin/AdminEmailSettingsView.vue
index 780152f..f51b2fa 100644
--- a/apps/frontend/src/views/admin/AdminEmailSettingsView.vue
+++ b/apps/frontend/src/views/admin/AdminEmailSettingsView.vue
@@ -28,6 +28,8 @@ const form = reactive({
smtp_use_ssl: true,
notify_token_expiring: true,
notify_check_in_success: true,
+ require_admin_approval_for_registration: true,
+ warn_unverified_email_before_approval: true,
})
const passwordState = computed(() => {
@@ -47,6 +49,8 @@ function hydrate(next: EmailNotificationSettings) {
form.smtp_use_ssl = next.smtp_use_ssl
form.notify_token_expiring = next.notify_token_expiring
form.notify_check_in_success = next.notify_check_in_success
+ form.require_admin_approval_for_registration = next.require_admin_approval_for_registration
+ form.warn_unverified_email_before_approval = next.warn_unverified_email_before_approval
}
async function load() {
@@ -63,6 +67,12 @@ async function load() {
}
async function save() {
+ if (!form.require_admin_approval_for_registration) {
+ const ok = window.confirm(
+ '关闭管理员审批后,新注册用户可能绕过人工审核直接进入系统。确认关闭管理员审批?',
+ )
+ if (!ok) return
+ }
saving.value = true
error.value = ''
savedMessage.value = ''
@@ -74,6 +84,8 @@ async function save() {
smtp_use_ssl: form.smtp_use_ssl,
notify_token_expiring: form.notify_token_expiring,
notify_check_in_success: form.notify_check_in_success,
+ require_admin_approval_for_registration: form.require_admin_approval_for_registration,
+ warn_unverified_email_before_approval: form.warn_unverified_email_before_approval,
smtp_sender_password: form.smtp_sender_password || undefined,
clear_smtp_sender_password: form.clear_smtp_sender_password,
})
@@ -205,6 +217,34 @@ onMounted(load)
只影响过期前提醒,不影响已过期通知。
+
+
+
+