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 @@