mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
feat(auth): require verified email for approval
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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="创建时间"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = (
|
||||
"<p>您正在验证接龙自动打卡系统账号邮箱。</p>"
|
||||
f"<p>验证码:<strong>{_email_text(code)}</strong></p>"
|
||||
"<p>验证码 10 分钟内有效。如非本人操作,请忽略本邮件。</p>"
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user