mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 05:56:29 +00:00
feat(email): add admin notification settings
This commit is contained in:
@@ -6,9 +6,14 @@ from pydantic import BaseModel
|
||||
|
||||
from backend.models import get_db, User, CheckInTask
|
||||
from backend.schemas.check_in import BatchCheckInRequest
|
||||
from backend.schemas.email_settings import (
|
||||
EmailNotificationSettingsResponse,
|
||||
EmailNotificationSettingsUpdate,
|
||||
)
|
||||
from backend.schemas.user import UserResponse
|
||||
from backend.services.check_in_service import CheckInService
|
||||
from backend.services.admin_service import AdminService
|
||||
from backend.services.email_settings_service import EmailSettingsService
|
||||
from backend.dependencies import get_current_admin_user
|
||||
from backend.config import settings
|
||||
from backend.exceptions import BaseAPIException
|
||||
@@ -25,6 +30,49 @@ class BatchToggleTasksRequest(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.get(
|
||||
"/email_settings",
|
||||
response_model=EmailNotificationSettingsResponse,
|
||||
summary="获取邮件与通知设置",
|
||||
)
|
||||
async def get_email_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""获取管理员可配置的 SMTP 与通知策略设置。"""
|
||||
try:
|
||||
return EmailSettingsService.get_response(db)
|
||||
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(
|
||||
"/email_settings",
|
||||
response_model=EmailNotificationSettingsResponse,
|
||||
summary="更新邮件与通知设置",
|
||||
)
|
||||
async def update_email_settings(
|
||||
request: EmailNotificationSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
):
|
||||
"""更新管理员可配置的 SMTP 与通知策略设置。"""
|
||||
try:
|
||||
return EmailSettingsService.update_response(db, request)
|
||||
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("/batch_toggle_tasks", summary="批量启用/禁用任务")
|
||||
async def batch_toggle_tasks(
|
||||
request: BatchToggleTasksRequest,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
|
||||
def apply(conn: Connection) -> None:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS email_notification_settings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
smtp_server VARCHAR(255) NOT NULL DEFAULT '',
|
||||
smtp_port INTEGER NOT NULL DEFAULT 465,
|
||||
smtp_sender_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
smtp_sender_password VARCHAR(500) NOT NULL DEFAULT '',
|
||||
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,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
@@ -9,6 +9,9 @@ from sqlalchemy import Engine, text
|
||||
from sqlalchemy.engine import Connection
|
||||
|
||||
from backend.migration_steps.account_lockout import apply as apply_account_lockout
|
||||
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.models.database import engine as default_engine
|
||||
|
||||
@@ -86,6 +89,11 @@ MIGRATIONS: tuple[Migration, ...] = (
|
||||
description="Add and backfill check-in task thread identity.",
|
||||
apply=apply_task_thread_id,
|
||||
),
|
||||
Migration(
|
||||
id="2026050501_add_email_notification_settings",
|
||||
description="Add admin-managed email notification settings.",
|
||||
apply=apply_email_notification_settings,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,5 +3,15 @@ from backend.models.user import User
|
||||
from backend.models.check_in_task import CheckInTask
|
||||
from backend.models.check_in_record import CheckInRecord
|
||||
from backend.models.task_template import TaskTemplate
|
||||
from backend.models.email_settings import EmailNotificationSettings
|
||||
|
||||
__all__ = ["Base", "get_db", "init_db", "User", "CheckInTask", "CheckInRecord", "TaskTemplate"]
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_db",
|
||||
"init_db",
|
||||
"User",
|
||||
"CheckInTask",
|
||||
"CheckInRecord",
|
||||
"TaskTemplate",
|
||||
"EmailNotificationSettings",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from backend.models.database import Base
|
||||
|
||||
|
||||
class EmailNotificationSettings(Base):
|
||||
"""系统邮件配置与通知策略设置。"""
|
||||
|
||||
__tablename__ = "email_notification_settings"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
smtp_server: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
smtp_port: Mapped[int] = mapped_column(Integer, default=465, nullable=False)
|
||||
smtp_sender_email: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
smtp_sender_password: Mapped[str] = mapped_column(String(500), default="", nullable=False)
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), comment="创建时间"
|
||||
)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), onupdate=func.now(), comment="更新时间"
|
||||
)
|
||||
@@ -37,6 +37,10 @@ from backend.schemas.template import (
|
||||
TaskFromTemplateRequest,
|
||||
TemplatePreviewResponse,
|
||||
)
|
||||
from backend.schemas.email_settings import (
|
||||
EmailNotificationSettingsResponse,
|
||||
EmailNotificationSettingsUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserBase",
|
||||
@@ -68,4 +72,6 @@ __all__ = [
|
||||
"TemplateResponse",
|
||||
"TaskFromTemplateRequest",
|
||||
"TemplatePreviewResponse",
|
||||
"EmailNotificationSettingsResponse",
|
||||
"EmailNotificationSettingsUpdate",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
|
||||
class EmailNotificationSettingsBase(BaseModel):
|
||||
smtp_server: str = Field("", max_length=255, description="SMTP 服务器地址")
|
||||
smtp_port: int = Field(465, ge=1, le=65535, description="SMTP 端口")
|
||||
smtp_sender_email: str = Field("", max_length=255, description="发件邮箱")
|
||||
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="是否通知打卡成功")
|
||||
|
||||
@field_validator("smtp_server", "smtp_sender_email", mode="before")
|
||||
@classmethod
|
||||
def strip_text(cls, value: object) -> object:
|
||||
if isinstance(value, str):
|
||||
return value.strip()
|
||||
return value
|
||||
|
||||
@field_validator("smtp_sender_email")
|
||||
@classmethod
|
||||
def validate_sender_email(cls, value: str) -> str:
|
||||
if value == "":
|
||||
return value
|
||||
try:
|
||||
return str(validate_email(value, check_deliverability=False).normalized)
|
||||
except EmailNotValidError as exc:
|
||||
raise ValueError("发件邮箱格式无效") from exc
|
||||
|
||||
|
||||
class EmailNotificationSettingsUpdate(EmailNotificationSettingsBase):
|
||||
smtp_sender_password: str | None = Field(
|
||||
None,
|
||||
max_length=500,
|
||||
description="新 SMTP 密码;省略表示保留现有值",
|
||||
)
|
||||
clear_smtp_sender_password: bool = Field(False, description="是否清空 SMTP 密码")
|
||||
|
||||
@field_validator("smtp_sender_password")
|
||||
@classmethod
|
||||
def normalize_password(cls, value: str | None) -> str | None:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class EmailNotificationSettingsResponse(EmailNotificationSettingsBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
has_smtp_sender_password: bool
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import User
|
||||
from backend.services.email_settings_service import EmailSettingsService
|
||||
from backend.services.email_templates import EmailTemplate, SafeHtml, render_email_template
|
||||
from backend.utils.time_helpers import minutes_until_expiry, parse_jwt_exp
|
||||
from backend.workers.email_notifier import EmailNotifier
|
||||
@@ -229,6 +230,10 @@ class EmailService:
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
if not EmailSettingsService.is_token_expiring_notification_enabled():
|
||||
logger.info("Token 即将过期通知已关闭,跳过发送")
|
||||
return False
|
||||
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过 Token 过期通知")
|
||||
@@ -291,6 +296,10 @@ class EmailService:
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
if success and not EmailSettingsService.is_check_in_success_notification_enabled():
|
||||
logger.info("打卡成功通知已关闭,跳过发送")
|
||||
return False
|
||||
|
||||
user_email = user.email
|
||||
if user_email is None:
|
||||
logger.info(f"用户 {user.alias} 未设置邮箱,跳过打卡通知")
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models import EmailNotificationSettings
|
||||
from backend.models.database import SessionLocal
|
||||
from backend.schemas.email_settings import (
|
||||
EmailNotificationSettingsResponse,
|
||||
EmailNotificationSettingsUpdate,
|
||||
)
|
||||
|
||||
SETTINGS_ROW_ID = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailSettingsSnapshot:
|
||||
id: int
|
||||
smtp_server: str
|
||||
smtp_port: int
|
||||
smtp_sender_email: str
|
||||
smtp_sender_password: str
|
||||
smtp_use_ssl: bool
|
||||
notify_token_expiring: bool
|
||||
notify_check_in_success: bool
|
||||
has_smtp_sender_password: bool
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class EmailSettingsService:
|
||||
"""读取和更新系统邮件配置。"""
|
||||
|
||||
@staticmethod
|
||||
def _defaults() -> EmailNotificationSettings:
|
||||
return EmailNotificationSettings(
|
||||
id=SETTINGS_ROW_ID,
|
||||
smtp_server=settings.SMTP_SERVER,
|
||||
smtp_port=settings.SMTP_PORT,
|
||||
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
|
||||
smtp_sender_password=settings.SMTP_SENDER_PASSWORD,
|
||||
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _default_snapshot() -> EmailSettingsSnapshot:
|
||||
password = str(settings.SMTP_SENDER_PASSWORD or "")
|
||||
return EmailSettingsSnapshot(
|
||||
id=SETTINGS_ROW_ID,
|
||||
smtp_server=settings.SMTP_SERVER,
|
||||
smtp_port=settings.SMTP_PORT,
|
||||
smtp_sender_email=settings.SMTP_SENDER_EMAIL,
|
||||
smtp_sender_password=password,
|
||||
smtp_use_ssl=settings.SMTP_USE_SSL,
|
||||
notify_token_expiring=True,
|
||||
notify_check_in_success=True,
|
||||
has_smtp_sender_password=bool(password),
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create(db: Session) -> EmailNotificationSettings:
|
||||
row = (
|
||||
db.query(EmailNotificationSettings)
|
||||
.filter(EmailNotificationSettings.id == SETTINGS_ROW_ID)
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
|
||||
row = EmailSettingsService._defaults()
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _to_snapshot(row: EmailNotificationSettings) -> EmailSettingsSnapshot:
|
||||
password = str(row.smtp_sender_password or "")
|
||||
return EmailSettingsSnapshot(
|
||||
id=row.id,
|
||||
smtp_server=str(row.smtp_server or ""),
|
||||
smtp_port=int(row.smtp_port or 0),
|
||||
smtp_sender_email=str(row.smtp_sender_email or ""),
|
||||
smtp_sender_password=password,
|
||||
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),
|
||||
has_smtp_sender_password=bool(password),
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_response(snapshot: EmailSettingsSnapshot) -> EmailNotificationSettingsResponse:
|
||||
return EmailNotificationSettingsResponse(
|
||||
id=snapshot.id,
|
||||
smtp_server=snapshot.smtp_server,
|
||||
smtp_port=snapshot.smtp_port,
|
||||
smtp_sender_email=snapshot.smtp_sender_email,
|
||||
smtp_use_ssl=snapshot.smtp_use_ssl,
|
||||
notify_token_expiring=snapshot.notify_token_expiring,
|
||||
notify_check_in_success=snapshot.notify_check_in_success,
|
||||
has_smtp_sender_password=snapshot.has_smtp_sender_password,
|
||||
created_at=snapshot.created_at,
|
||||
updated_at=snapshot.updated_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_snapshot(db: Session) -> EmailSettingsSnapshot:
|
||||
return EmailSettingsService._to_snapshot(EmailSettingsService._get_or_create(db))
|
||||
|
||||
@staticmethod
|
||||
def get_response(db: Session) -> EmailNotificationSettingsResponse:
|
||||
return EmailSettingsService._to_response(EmailSettingsService.get_snapshot(db))
|
||||
|
||||
@staticmethod
|
||||
def update_settings(
|
||||
db: Session, payload: EmailNotificationSettingsUpdate
|
||||
) -> EmailSettingsSnapshot:
|
||||
row = EmailSettingsService._get_or_create(db)
|
||||
row.smtp_server = payload.smtp_server
|
||||
row.smtp_port = payload.smtp_port
|
||||
row.smtp_sender_email = str(payload.smtp_sender_email)
|
||||
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
|
||||
|
||||
if payload.clear_smtp_sender_password:
|
||||
row.smtp_sender_password = ""
|
||||
elif payload.smtp_sender_password is not None:
|
||||
row.smtp_sender_password = payload.smtp_sender_password
|
||||
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return EmailSettingsService._to_snapshot(row)
|
||||
|
||||
@staticmethod
|
||||
def update_response(
|
||||
db: Session, payload: EmailNotificationSettingsUpdate
|
||||
) -> EmailNotificationSettingsResponse:
|
||||
return EmailSettingsService._to_response(EmailSettingsService.update_settings(db, payload))
|
||||
|
||||
@staticmethod
|
||||
def get_smtp_config() -> dict[str, object] | None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
snapshot = EmailSettingsService.get_snapshot(db)
|
||||
except SQLAlchemyError:
|
||||
snapshot = EmailSettingsService._default_snapshot()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not snapshot.smtp_server or not snapshot.smtp_sender_email or not snapshot.smtp_port:
|
||||
return None
|
||||
|
||||
return {
|
||||
"smtp_server": snapshot.smtp_server,
|
||||
"smtp_port": snapshot.smtp_port,
|
||||
"sender_email": snapshot.smtp_sender_email,
|
||||
"sender_password": snapshot.smtp_sender_password,
|
||||
"use_ssl": snapshot.smtp_use_ssl,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def is_token_expiring_notification_enabled() -> bool:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
return EmailSettingsService.get_snapshot(db).notify_token_expiring
|
||||
except SQLAlchemyError:
|
||||
return EmailSettingsService._default_snapshot().notify_token_expiring
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@staticmethod
|
||||
def is_check_in_success_notification_enabled() -> bool:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
try:
|
||||
return EmailSettingsService.get_snapshot(db).notify_check_in_success
|
||||
except SQLAlchemyError:
|
||||
return EmailSettingsService._default_snapshot().notify_check_in_success
|
||||
finally:
|
||||
db.close()
|
||||
@@ -14,7 +14,7 @@ from email.mime.multipart import MIMEMultipart
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.email_settings_service import EmailSettingsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,30 +25,19 @@ class EmailNotifier:
|
||||
@staticmethod
|
||||
def get_email_config() -> Optional[dict]:
|
||||
"""
|
||||
从环境变量读取邮件配置
|
||||
读取有效邮件配置
|
||||
|
||||
Returns:
|
||||
dict: 邮件配置,如果配置不完整则返回 None
|
||||
"""
|
||||
# 检查必要的邮件配置是否存在
|
||||
if not settings.SMTP_SERVER or not settings.SMTP_SENDER_EMAIL:
|
||||
email_config = EmailSettingsService.get_smtp_config()
|
||||
if not email_config:
|
||||
logger.debug(
|
||||
"邮件配置未完成(SMTP_SERVER 或 SMTP_SENDER_EMAIL 为空),邮件发送功能已禁用"
|
||||
)
|
||||
return None
|
||||
|
||||
if not settings.SMTP_PORT:
|
||||
logger.debug("邮件配置未完成(SMTP_PORT 为空),邮件发送功能已禁用")
|
||||
return None
|
||||
|
||||
# 返回配置字典
|
||||
return {
|
||||
"smtp_server": settings.SMTP_SERVER,
|
||||
"smtp_port": settings.SMTP_PORT,
|
||||
"sender_email": settings.SMTP_SENDER_EMAIL,
|
||||
"sender_password": settings.SMTP_SENDER_PASSWORD,
|
||||
"use_ssl": settings.SMTP_USE_SSL,
|
||||
}
|
||||
return email_config
|
||||
|
||||
@staticmethod
|
||||
def send_email(
|
||||
|
||||
Reference in New Issue
Block a user