diff --git a/apps/backend/migration_steps/email_approval_policy.py b/apps/backend/migration_steps/email_approval_policy.py new file mode 100644 index 0000000..325b2ee --- /dev/null +++ b/apps/backend/migration_steps/email_approval_policy.py @@ -0,0 +1,31 @@ +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 apply(conn: Connection) -> None: + columns = _table_columns(conn, "email_notification_settings") + if "require_verified_email_for_approval" not in columns: + conn.execute( + text( + "ALTER TABLE email_notification_settings " + "ADD COLUMN require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1" + ) + ) + conn.commit() + columns = _table_columns(conn, "email_notification_settings") + + if "warn_unverified_email_before_approval" in columns: + conn.execute( + text( + "ALTER TABLE email_notification_settings " + "DROP COLUMN warn_unverified_email_before_approval" + ) + ) + conn.commit() diff --git a/apps/backend/migration_steps/email_notification_settings.py b/apps/backend/migration_steps/email_notification_settings.py index ef13352..310cc8a 100644 --- a/apps/backend/migration_steps/email_notification_settings.py +++ b/apps/backend/migration_steps/email_notification_settings.py @@ -18,7 +18,7 @@ def apply(conn: Connection) -> None: 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, + require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME ) diff --git a/apps/backend/migration_steps/legacy_user_email_verification.py b/apps/backend/migration_steps/legacy_user_email_verification.py new file mode 100644 index 0000000..06a489b --- /dev/null +++ b/apps/backend/migration_steps/legacy_user_email_verification.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import text +from sqlalchemy.engine import Connection + + +def apply(conn: Connection) -> None: + verified_at = datetime.now(timezone.utc).isoformat() + conn.execute( + text( + """ + UPDATE users + SET email_verified_at = :verified_at + WHERE email_verified_at IS NULL + AND email IS NOT NULL + AND email != '' + AND is_approved = 1 + """ + ), + {"verified_at": verified_at}, + ) + conn.commit() diff --git a/apps/backend/migration_steps/user_email_verification.py b/apps/backend/migration_steps/user_email_verification.py index 53b83b4..6c49251 100644 --- a/apps/backend/migration_steps/user_email_verification.py +++ b/apps/backend/migration_steps/user_email_verification.py @@ -19,6 +19,16 @@ def _add_column_if_missing( return columns +def _drop_column_if_present( + conn: Connection, table_name: str, columns: set[str], column_name: str +) -> set[str]: + if column_name in columns: + conn.execute(text(f"ALTER TABLE {table_name} DROP COLUMN {column_name}")) + 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( @@ -51,10 +61,16 @@ def apply(conn: Connection) -> None: "require_admin_approval_for_registration", "require_admin_approval_for_registration BOOLEAN NOT NULL DEFAULT 1", ) - _add_column_if_missing( + settings_columns = _add_column_if_missing( + conn, + "email_notification_settings", + settings_columns, + "require_verified_email_for_approval", + "require_verified_email_for_approval BOOLEAN NOT NULL DEFAULT 1", + ) + _drop_column_if_present( 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 82b7c6e..6767144 100644 --- a/apps/backend/migrations.py +++ b/apps/backend/migrations.py @@ -12,6 +12,12 @@ from backend.migration_steps.account_lockout import apply as apply_account_locko from backend.migration_steps.email_notification_settings import ( apply as apply_email_notification_settings, ) +from backend.migration_steps.email_approval_policy import ( + apply as apply_email_approval_policy, +) +from backend.migration_steps.legacy_user_email_verification import ( + apply as apply_legacy_user_email_verification, +) 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, @@ -102,6 +108,16 @@ MIGRATIONS: tuple[Migration, ...] = ( description="Add user email verification fields and registration approval policy flags.", apply=apply_user_email_verification, ), + Migration( + id="2026050602_backfill_legacy_verified_emails", + description="Trust existing approved user email addresses after verification rollout.", + apply=apply_legacy_user_email_verification, + ), + Migration( + id="2026050603_remove_legacy_email_approval_warning", + description="Replace legacy unverified-email warning setting with approval email policy.", + apply=apply_email_approval_policy, + ), ) diff --git a/apps/backend/models/email_settings.py b/apps/backend/models/email_settings.py index 9fd4f5d..1554d10 100644 --- a/apps/backend/models/email_settings.py +++ b/apps/backend/models/email_settings.py @@ -25,7 +25,7 @@ class EmailNotificationSettings(Base): require_admin_approval_for_registration: Mapped[bool] = mapped_column( Boolean, default=True, nullable=False ) - warn_unverified_email_before_approval: Mapped[bool] = mapped_column( + require_verified_email_for_approval: Mapped[bool] = mapped_column( Boolean, default=True, nullable=False ) created_at: Mapped[datetime] = mapped_column( diff --git a/apps/backend/schemas/email_settings.py b/apps/backend/schemas/email_settings.py index e3263ea..f899a61 100644 --- a/apps/backend/schemas/email_settings.py +++ b/apps/backend/schemas/email_settings.py @@ -16,8 +16,8 @@ class EmailNotificationSettingsBase(BaseModel): require_admin_approval_for_registration: bool = Field( True, description="新注册是否需要管理员审批" ) - warn_unverified_email_before_approval: bool = Field( - True, description="审批未验证邮箱用户时是否警告" + require_verified_email_for_approval: bool = Field( + True, description="审批前是否要求用户完成邮箱验证" ) @field_validator("smtp_server", "smtp_sender_email", mode="before") diff --git a/apps/backend/services/admin_service.py b/apps/backend/services/admin_service.py index 036b0bc..0815ead 100644 --- a/apps/backend/services/admin_service.py +++ b/apps/backend/services/admin_service.py @@ -20,7 +20,7 @@ class AdminService: 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() + EmailSettingsService.is_verified_email_required_for_approval() and not has_verified_email ) if should_warn and not allow_unverified_email: diff --git a/apps/backend/services/email_settings_service.py b/apps/backend/services/email_settings_service.py index 4249bfc..6a7cfe1 100644 --- a/apps/backend/services/email_settings_service.py +++ b/apps/backend/services/email_settings_service.py @@ -28,7 +28,7 @@ class EmailSettingsSnapshot: notify_token_expiring: bool notify_check_in_success: bool require_admin_approval_for_registration: bool - warn_unverified_email_before_approval: bool + require_verified_email_for_approval: bool has_smtp_sender_password: bool created_at: datetime | None = None updated_at: datetime | None = None @@ -49,7 +49,7 @@ class EmailSettingsService: notify_token_expiring=True, notify_check_in_success=True, require_admin_approval_for_registration=True, - warn_unverified_email_before_approval=True, + require_verified_email_for_approval=True, ) @staticmethod @@ -65,7 +65,7 @@ class EmailSettingsService: notify_token_expiring=True, notify_check_in_success=True, require_admin_approval_for_registration=True, - warn_unverified_email_before_approval=True, + require_verified_email_for_approval=True, has_smtp_sender_password=bool(password), created_at=None, updated_at=None, @@ -102,7 +102,7 @@ class EmailSettingsService: require_admin_approval_for_registration=bool( row.require_admin_approval_for_registration ), - warn_unverified_email_before_approval=bool(row.warn_unverified_email_before_approval), + require_verified_email_for_approval=bool(row.require_verified_email_for_approval), has_smtp_sender_password=bool(password), created_at=row.created_at, updated_at=row.updated_at, @@ -119,7 +119,7 @@ class EmailSettingsService: 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, + require_verified_email_for_approval=snapshot.require_verified_email_for_approval, has_smtp_sender_password=snapshot.has_smtp_sender_password, created_at=snapshot.created_at, updated_at=snapshot.updated_at, @@ -147,7 +147,7 @@ class EmailSettingsService: row.require_admin_approval_for_registration = ( payload.require_admin_approval_for_registration ) - row.warn_unverified_email_before_approval = payload.warn_unverified_email_before_approval + row.require_verified_email_for_approval = payload.require_verified_email_for_approval if payload.clear_smtp_sender_password: row.smtp_sender_password = "" @@ -223,14 +223,12 @@ class EmailSettingsService: db.close() @staticmethod - def should_warn_unverified_email_before_approval() -> bool: + def is_verified_email_required_for_approval() -> bool: db = SessionLocal() try: try: - return EmailSettingsService.get_snapshot(db).warn_unverified_email_before_approval + return EmailSettingsService.get_snapshot(db).require_verified_email_for_approval except SQLAlchemyError: - return ( - EmailSettingsService._default_snapshot().warn_unverified_email_before_approval - ) + return EmailSettingsService._default_snapshot().require_verified_email_for_approval finally: db.close() diff --git a/apps/backend/services/user_service.py b/apps/backend/services/user_service.py index 4367c75..0fa51d4 100644 --- a/apps/backend/services/user_service.py +++ b/apps/backend/services/user_service.py @@ -257,13 +257,11 @@ class UserService: user.alias = update_data["alias"] logger.info(f"用户 ID {user_id} 别名更新: {user.alias}") - # 更新邮箱 + # 邮箱必须走 /users/me/email 的验证码流程,不能从普通资料保存绕过。 if "email" in update_data: 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}") + raise ValueError("邮箱修改需要先完成验证码验证") # 更新密码 if "new_password" in update_data and update_data["new_password"]: diff --git a/apps/frontend/src/api/index.ts b/apps/frontend/src/api/index.ts index f3ac5a6..bad6dc8 100644 --- a/apps/frontend/src/api/index.ts +++ b/apps/frontend/src/api/index.ts @@ -48,12 +48,8 @@ export const userApi = { 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 - current_password?: string - new_password?: string - }) => apiClient.put('/api/users/me/profile', payload), + updateProfile: (payload: { alias?: string; current_password?: string; new_password?: string }) => + apiClient.put('/api/users/me/profile', payload), list: (params: Record = {}) => apiClient.get('/api/users', params), create: (payload: Partial & { password?: string }) => apiClient.post('/api/users', payload), diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index f3f951f..a25baff 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -215,7 +215,7 @@ export interface EmailNotificationSettings { notify_token_expiring: boolean notify_check_in_success: boolean require_admin_approval_for_registration: boolean - warn_unverified_email_before_approval: boolean + require_verified_email_for_approval: boolean has_smtp_sender_password: boolean created_at?: string | null updated_at?: string | null @@ -229,7 +229,7 @@ export interface EmailNotificationSettingsUpdate { notify_token_expiring: boolean notify_check_in_success: boolean require_admin_approval_for_registration: boolean - warn_unverified_email_before_approval: boolean + require_verified_email_for_approval: boolean smtp_sender_password?: string clear_smtp_sender_password?: boolean } diff --git a/apps/frontend/src/views/SettingsView.vue b/apps/frontend/src/views/SettingsView.vue index 9d0fd08..e298bb6 100644 --- a/apps/frontend/src/views/SettingsView.vue +++ b/apps/frontend/src/views/SettingsView.vue @@ -1,6 +1,6 @@ @@ -92,10 +132,6 @@ onMounted(load) 别名 -
+
+
+
+

邮箱验证

+ + {{ emailVerified ? '已验证' : '待验证' }} + +
+
+
+ +
+ +
+ + +
+
+
+ {{ emailMessage }} +
+
+
+