From d72dd1c5556fb69d0df8675d190ea574076eb9e0 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Fri, 20 Mar 2026 11:06:25 +0800 Subject: [PATCH] =?UTF-8?q?feature=EF=BC=9A=E6=96=B0=E5=A2=9E=E9=82=AE?= =?UTF-8?q?=E7=AE=B1=E6=B3=A8=E5=86=8C=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-build.yml | 17 +- backend/.env.example | 17 + ...0320_0936_6eb27fce64de_新增stmp相关配置.py | 64 ++ ...0320_0939_6ff45db05863_新增stmp相关配置.py | 70 ++ backend/app/api/auth.py | 740 +++++++++++----- backend/app/api/settings.py | 153 +++- backend/app/config.py | 15 + backend/app/models/settings.py | 15 + backend/app/schemas/settings.py | 41 + backend/app/services/email_service.py | 71 ++ backend/requirements.txt | 1 + frontend/src/pages/Login.tsx | 794 +++++++++++++++--- frontend/src/pages/ProjectList.tsx | 30 +- frontend/src/pages/SystemSettings.tsx | 289 +++++++ frontend/src/services/api.ts | 47 +- frontend/src/types/index.ts | 59 ++ 16 files changed, 2074 insertions(+), 349 deletions(-) create mode 100644 backend/alembic/postgres/versions/20260320_0936_6eb27fce64de_新增stmp相关配置.py create mode 100644 backend/alembic/sqlite/versions/20260320_0939_6ff45db05863_新增stmp相关配置.py create mode 100644 backend/app/services/email_service.py create mode 100644 frontend/src/pages/SystemSettings.tsx diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d67c967..3157d58 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,6 +8,7 @@ on: env: DOCKER_IMAGE: mumujie/mumuainovel + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-and-push: @@ -15,29 +16,29 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 with: platforms: linux/amd64,linux/arm64 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 with: driver-opts: | image=moby/buildkit:master network=host - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata (tags, labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ${{ env.DOCKER_IMAGE }} tags: | @@ -53,7 +54,7 @@ jobs: type=sha,prefix=sha- - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . file: ./Dockerfile @@ -68,10 +69,10 @@ jobs: - name: Update Docker Hub Description if: startsWith(github.ref, 'refs/tags/v') - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: ${{ env.DOCKER_IMAGE }} readme-filepath: ./README.md - continue-on-error: true \ No newline at end of file + continue-on-error: true diff --git a/backend/.env.example b/backend/.env.example index 64faf7b..6ab6ae1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -93,6 +93,23 @@ LOCAL_AUTH_DISPLAY_NAME=本地管理员 SESSION_EXPIRE_MINUTES=120 SESSION_REFRESH_THRESHOLD_MINUTES=30 +# ========================================== +# SMTP 默认配置(可在系统设置中被管理员覆盖) +# ========================================== +SMTP_PROVIDER=qq +SMTP_HOST=smtp.qq.com +SMTP_PORT=465 +SMTP_USERNAME=your-email@qq.com +SMTP_PASSWORD=your-qq-smtp-auth-code +SMTP_USE_TLS=false +SMTP_USE_SSL=true +SMTP_FROM_EMAIL=your-email@qq.com +SMTP_FROM_NAME=MuMuAINovel +EMAIL_AUTH_ENABLED=true +EMAIL_REGISTER_ENABLED=true +EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 +EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60 + # ========================================== # 提示词工坊配置 # ========================================== diff --git a/backend/alembic/postgres/versions/20260320_0936_6eb27fce64de_新增stmp相关配置.py b/backend/alembic/postgres/versions/20260320_0936_6eb27fce64de_新增stmp相关配置.py new file mode 100644 index 0000000..7dbda5a --- /dev/null +++ b/backend/alembic/postgres/versions/20260320_0936_6eb27fce64de_新增stmp相关配置.py @@ -0,0 +1,64 @@ +"""新增stmp相关配置 + +Revision ID: 6eb27fce64de +Revises: 8e3ac0236b27 +Create Date: 2026-03-20 09:36:42.899296 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6eb27fce64de' +down_revision: Union[str, None] = '8e3ac0236b27' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('settings', sa.Column('smtp_provider', sa.String(length=50), server_default='qq', nullable=False, comment='SMTP 提供商')) + op.add_column('settings', sa.Column('smtp_host', sa.String(length=255), nullable=True, comment='SMTP 主机')) + op.add_column('settings', sa.Column('smtp_port', sa.Integer(), server_default='465', nullable=False, comment='SMTP 端口')) + op.add_column('settings', sa.Column('smtp_username', sa.String(length=255), nullable=True, comment='SMTP 用户名')) + op.add_column('settings', sa.Column('smtp_password', sa.String(length=500), nullable=True, comment='SMTP 密码或授权码')) + op.add_column('settings', sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS')) + op.add_column('settings', sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL')) + op.add_column('settings', sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱')) + op.add_column('settings', sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称')) + op.add_column('settings', sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证')) + op.add_column('settings', sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册')) + op.add_column('settings', sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)')) + op.add_column('settings', sa.Column('verification_resend_interval_seconds', sa.Integer(), server_default='60', nullable=False, comment='验证码重发间隔(秒)')) + op.alter_column('settings', 'cover_enabled', + existing_type=sa.BOOLEAN(), + server_default='0', + existing_comment='是否启用封面图片生成', + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('settings', 'cover_enabled', + existing_type=sa.BOOLEAN(), + server_default=None, + existing_comment='是否启用封面图片生成', + existing_nullable=False) + op.drop_column('settings', 'verification_resend_interval_seconds') + op.drop_column('settings', 'verification_code_ttl_minutes') + op.drop_column('settings', 'email_register_enabled') + op.drop_column('settings', 'email_auth_enabled') + op.drop_column('settings', 'smtp_from_name') + op.drop_column('settings', 'smtp_from_email') + op.drop_column('settings', 'smtp_use_ssl') + op.drop_column('settings', 'smtp_use_tls') + op.drop_column('settings', 'smtp_password') + op.drop_column('settings', 'smtp_username') + op.drop_column('settings', 'smtp_port') + op.drop_column('settings', 'smtp_host') + op.drop_column('settings', 'smtp_provider') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/alembic/sqlite/versions/20260320_0939_6ff45db05863_新增stmp相关配置.py b/backend/alembic/sqlite/versions/20260320_0939_6ff45db05863_新增stmp相关配置.py new file mode 100644 index 0000000..d61def2 --- /dev/null +++ b/backend/alembic/sqlite/versions/20260320_0939_6ff45db05863_新增stmp相关配置.py @@ -0,0 +1,70 @@ +"""新增stmp相关配置 + +Revision ID: 6ff45db05863 +Revises: 17ce752ed7cc +Create Date: 2026-03-20 09:39:23.544434 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '6ff45db05863' +down_revision: Union[str, None] = '17ce752ed7cc' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.alter_column('cover_status', + existing_type=sa.VARCHAR(length=20), + server_default=None, + existing_nullable=False) + + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('smtp_provider', sa.String(length=50), server_default='qq', nullable=False, comment='SMTP 提供商')) + batch_op.add_column(sa.Column('smtp_host', sa.String(length=255), nullable=True, comment='SMTP 主机')) + batch_op.add_column(sa.Column('smtp_port', sa.Integer(), server_default='465', nullable=False, comment='SMTP 端口')) + batch_op.add_column(sa.Column('smtp_username', sa.String(length=255), nullable=True, comment='SMTP 用户名')) + batch_op.add_column(sa.Column('smtp_password', sa.String(length=500), nullable=True, comment='SMTP 密码或授权码')) + batch_op.add_column(sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS')) + batch_op.add_column(sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL')) + batch_op.add_column(sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱')) + batch_op.add_column(sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称')) + batch_op.add_column(sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证')) + batch_op.add_column(sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册')) + batch_op.add_column(sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)')) + batch_op.add_column(sa.Column('verification_resend_interval_seconds', sa.Integer(), server_default='60', nullable=False, comment='验证码重发间隔(秒)')) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_column('verification_resend_interval_seconds') + batch_op.drop_column('verification_code_ttl_minutes') + batch_op.drop_column('email_register_enabled') + batch_op.drop_column('email_auth_enabled') + batch_op.drop_column('smtp_from_name') + batch_op.drop_column('smtp_from_email') + batch_op.drop_column('smtp_use_ssl') + batch_op.drop_column('smtp_use_tls') + batch_op.drop_column('smtp_password') + batch_op.drop_column('smtp_username') + batch_op.drop_column('smtp_port') + batch_op.drop_column('smtp_host') + batch_op.drop_column('smtp_provider') + + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.alter_column('cover_status', + existing_type=sa.VARCHAR(length=20), + server_default=sa.text("'none'"), + existing_nullable=False) + + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 97655ea..0b4dd51 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -1,25 +1,36 @@ """ -认证 API - LinuxDO OAuth2 登录 + 本地账户登录 +认证 API - LinuxDO OAuth2 登录 + 本地账户登录 + 邮箱验证码注册/登录 """ from fastapi import APIRouter, HTTPException, Response, Request from fastapi.responses import RedirectResponse from pydantic import BaseModel from typing import Optional import hashlib +import random +import re from datetime import datetime, timedelta, timezone +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + from app.services.oauth_service import LinuxDOOAuthService -from app.user_manager import user_manager +from app.user_manager import user_manager, User as UserDTO from app.user_password import password_manager from app.logger import get_logger from app.config import settings +from app.database import get_engine +from app.models.user import User as UserModel +from app.models.settings import Settings as SettingsModel +from app.services.email_service import email_service # 中国时区 UTC+8 CHINA_TZ = timezone(timedelta(hours=8)) + def get_china_now(): """获取中国当前时间""" return datetime.now(CHINA_TZ) + logger = get_logger(__name__) router = APIRouter(prefix="/auth", tags=["认证"]) @@ -30,6 +41,11 @@ oauth_service = LinuxDOOAuthService() # State 临时存储(生产环境应使用 Redis) _state_storage = {} +# 邮箱验证码临时存储(生产环境应使用 Redis) +_email_verification_storage = {} + +EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") + class AuthUrlResponse(BaseModel): auth_url: str @@ -42,8 +58,35 @@ class LocalLoginRequest(BaseModel): password: str +class EmailLoginRequest(BaseModel): + """邮箱验证码登录请求""" + email: str + code: str + + +class EmailSendCodeRequest(BaseModel): + """邮箱验证码发送请求""" + email: str + scene: str = "register" + + +class EmailRegisterRequest(BaseModel): + """邮箱注册请求""" + email: str + code: str + password: str + display_name: Optional[str] = None + + +class EmailResetPasswordRequest(BaseModel): + """邮箱重置密码请求""" + email: str + code: str + new_password: str + + class LocalLoginResponse(BaseModel): - """本地登录响应""" + """登录响应""" success: bool message: str user: Optional[dict] = None @@ -68,116 +111,287 @@ class PasswordStatusResponse(BaseModel): default_password: Optional[str] = None +async def _get_global_session() -> AsyncSession: + """获取全局数据库会话""" + engine = await get_engine("_global_users_") + session_maker = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + return session_maker() + + +async def _get_auth_runtime_settings() -> dict: + """获取认证相关运行时配置,优先读取管理员系统设置,其次回退到 .env""" + runtime = { + "email_auth_enabled": settings.EMAIL_AUTH_ENABLED, + "email_register_enabled": settings.EMAIL_REGISTER_ENABLED, + "verification_code_ttl_minutes": settings.EMAIL_VERIFICATION_CODE_TTL_MINUTES, + "verification_resend_interval_seconds": settings.EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS, + "smtp_host": settings.SMTP_HOST, + "smtp_port": settings.SMTP_PORT, + "smtp_username": settings.SMTP_USERNAME, + "smtp_password": settings.SMTP_PASSWORD, + "smtp_use_tls": settings.SMTP_USE_TLS, + "smtp_use_ssl": settings.SMTP_USE_SSL, + "smtp_from_email": settings.SMTP_FROM_EMAIL, + "smtp_from_name": settings.SMTP_FROM_NAME, + } + + async with await _get_global_session() as session: + result = await session.execute( + select(SettingsModel) + .join(UserModel, UserModel.user_id == SettingsModel.user_id) + .where(UserModel.is_admin == True) + .order_by(SettingsModel.updated_at.desc()) + .limit(1) + ) + admin_settings = result.scalar_one_or_none() + + if admin_settings: + runtime.update({ + "email_auth_enabled": admin_settings.email_auth_enabled, + "email_register_enabled": admin_settings.email_register_enabled, + "verification_code_ttl_minutes": admin_settings.verification_code_ttl_minutes, + "verification_resend_interval_seconds": admin_settings.verification_resend_interval_seconds, + "smtp_host": admin_settings.smtp_host, + "smtp_port": admin_settings.smtp_port, + "smtp_username": admin_settings.smtp_username, + "smtp_password": admin_settings.smtp_password, + "smtp_use_tls": admin_settings.smtp_use_tls, + "smtp_use_ssl": admin_settings.smtp_use_ssl, + "smtp_from_email": admin_settings.smtp_from_email, + "smtp_from_name": admin_settings.smtp_from_name, + }) + + return runtime + + +async def _find_user_by_email(email: str) -> Optional[UserDTO]: + """按邮箱查找用户。邮箱用户的 username 字段即邮箱地址。""" + normalized_email = email.strip().lower() + async with await _get_global_session() as session: + result = await session.execute( + select(UserModel).where(UserModel.username == normalized_email) + ) + user = result.scalar_one_or_none() + if not user: + return None + return UserDTO(**user.to_dict()) + + +async def _create_email_user(email: str, display_name: Optional[str]) -> UserDTO: + """创建邮箱注册用户""" + normalized_email = email.strip().lower() + final_display_name = (display_name or normalized_email.split("@")[0]).strip() + if not final_display_name: + final_display_name = normalized_email.split("@")[0] + + user_id = f"email_{hashlib.md5(normalized_email.encode()).hexdigest()[:16]}" + + async with await _get_global_session() as session: + existing = await session.execute( + select(UserModel).where(UserModel.user_id == user_id) + ) + user = existing.scalar_one_or_none() + + if user: + raise HTTPException(status_code=400, detail="该邮箱已注册") + + user = UserModel( + user_id=user_id, + username=normalized_email, + display_name=final_display_name, + avatar_url=None, + trust_level=1, + is_admin=False, + linuxdo_id=user_id, + created_at=datetime.now(), + last_login=datetime.now(), + ) + session.add(user) + await session.commit() + await session.refresh(user) + + return UserDTO(**user.to_dict()) + + +async def _touch_user_last_login(user_id: str): + """更新最后登录时间""" + async with await _get_global_session() as session: + result = await session.execute( + select(UserModel).where(UserModel.user_id == user_id) + ) + user = result.scalar_one_or_none() + if not user: + return + + user.last_login = datetime.now() + await session.commit() + + +def _validate_email(email: str) -> str: + normalized_email = email.strip().lower() + if not normalized_email or len(normalized_email) > 255 or not EMAIL_REGEX.match(normalized_email): + raise HTTPException(status_code=400, detail="请输入有效的邮箱地址") + return normalized_email + + +def _validate_password(password: str): + if len(password) < 6: + raise HTTPException(status_code=400, detail="密码长度至少为6个字符") + + +def _set_login_cookies(response: Response, user_id: str): + """设置登录 Cookie""" + max_age = settings.SESSION_EXPIRE_MINUTES * 60 + response.set_cookie( + key="user_id", + value=user_id, + max_age=max_age, + httponly=True, + samesite="lax" + ) + + china_now = get_china_now() + expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) + expire_at = int(expire_time.timestamp()) + + response.set_cookie( + key="session_expire_at", + value=str(expire_at), + max_age=max_age, + httponly=False, + samesite="lax" + ) + + +def _generate_verification_code() -> str: + return f"{random.randint(0, 999999):06d}" + + +def _build_verification_mail_content(scene: str, code: str, ttl_minutes: int) -> tuple[str, str, str]: + scene_title_map = { + "register": "邮箱注册验证码", + "login": "邮箱登录验证码", + "reset_password": "重置密码验证码", + } + scene_desc_map = { + "register": "欢迎注册 MuMuAINovel。", + "login": "你正在使用邮箱验证码登录 MuMuAINovel。", + "reset_password": "你正在重置 MuMuAINovel 账号密码。", + } + + scene_title = scene_title_map.get(scene, "邮箱验证码") + scene_desc = scene_desc_map.get(scene, "你正在进行邮箱身份验证。") + subject = f"MuMuAINovel {scene_title}" + text_body = ( + f"{scene_desc}\n\n" + f"你的验证码是:{code}\n" + f"有效期:{ttl_minutes} 分钟\n\n" + f"如果这不是你的操作,请忽略本邮件。" + ) + html_body = f""" +
+

MuMuAINovel {scene_title}

+

{scene_desc}

+

你的验证码为:

+
+ {code} +
+

有效期:{ttl_minutes} 分钟

+

如果这不是你的操作,请忽略本邮件。

+
+ """ + return subject, text_body, html_body + + +def _get_verification_storage_key(scene: str, email: str) -> str: + return f"{scene}:{email}" + + +def _validate_verification_scene(scene: str) -> str: + normalized_scene = scene.strip().lower() + allowed_scenes = {"register", "login", "reset_password"} + if normalized_scene not in allowed_scenes: + raise HTTPException(status_code=400, detail="不支持的验证码场景") + return normalized_scene + + @router.get("/config") async def get_auth_config(): """获取认证配置信息""" + runtime = await _get_auth_runtime_settings() return { "local_auth_enabled": settings.LOCAL_AUTH_ENABLED, - "linuxdo_enabled": bool(settings.LINUXDO_CLIENT_ID and settings.LINUXDO_CLIENT_SECRET) + "linuxdo_enabled": bool(settings.LINUXDO_CLIENT_ID and settings.LINUXDO_CLIENT_SECRET), + "email_auth_enabled": runtime["email_auth_enabled"], + "email_register_enabled": runtime["email_register_enabled"], } @router.post("/local/login", response_model=LocalLoginResponse) async def local_login(request: LocalLoginRequest, response: Response): """本地账户登录(支持.env配置的管理员账号和Linux DO授权后绑定的账号)""" - # 检查是否启用本地登录 if not settings.LOCAL_AUTH_ENABLED: raise HTTPException(status_code=403, detail="本地账户登录未启用") - + logger.info(f"[本地登录] 尝试登录用户名: {request.username}") - - # 首先尝试查找 Linux DO 授权后绑定的账号 + all_users = await user_manager.get_all_users() target_user = None - + for user in all_users: - # 同时检查 users 表的 username 和 user_passwords 表的 username password_username = await password_manager.get_username(user.user_id) if user.username == request.username or password_username == request.username: target_user = user logger.info(f"[本地登录] 找到 Linux DO 授权用户: {user.user_id}") break - - # 如果找到了 Linux DO 授权的用户 + if target_user: - # 检查是否有密码 if not await password_manager.has_password(target_user.user_id): logger.warning(f"[本地登录] 用户 {target_user.user_id} 没有设置密码") raise HTTPException(status_code=401, detail="用户名或密码错误") - - # 验证密码 + if not await password_manager.verify_password(target_user.user_id, request.password): logger.warning(f"[本地登录] 用户 {target_user.user_id} 密码验证失败") raise HTTPException(status_code=401, detail="用户名或密码错误") - + logger.info(f"[本地登录] Linux DO 授权用户 {target_user.user_id} 登录成功") user = target_user else: - # 没有找到 Linux DO 用户,尝试 .env 配置的管理员账号 logger.info(f"[本地登录] 未找到 Linux DO 用户,检查 .env 管理员账号") - - # 检查是否配置了本地账户 + if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD: raise HTTPException(status_code=401, detail="用户名或密码错误") - - # 生成本地用户ID(使用用户名的hash) + user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}" - - # 检查用户是否存在 user = await user_manager.get_user(user_id) - - # 如果用户不存在,使用.env中的默认密码验证 + if not user: - # 验证用户名和密码(使用.env配置) if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD: raise HTTPException(status_code=401, detail="用户名或密码错误") - - # 创建本地用户 + user = await user_manager.create_or_update_from_linuxdo( linuxdo_id=user_id, username=request.username, display_name=settings.LOCAL_AUTH_DISPLAY_NAME, avatar_url=None, - trust_level=9 # 本地用户给予高信任级别 + trust_level=9 ) - - # 为新用户设置默认密码到数据库 + await password_manager.set_password(user.user_id, request.username, request.password) logger.info(f"[本地登录] 管理员用户 {user.user_id} 初始密码已设置到数据库") else: - # 用户已存在,使用数据库中的密码验证 if not await password_manager.verify_password(user.user_id, request.password): raise HTTPException(status_code=401, detail="用户名或密码错误") - + logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功") - - # Settings 将在首次访问设置页面时自动创建(延迟初始化) - - # 设置 Cookie(2小时有效) - max_age = settings.SESSION_EXPIRE_MINUTES * 60 - response.set_cookie( - key="user_id", - value=user.user_id, - max_age=max_age, - httponly=True, - samesite="lax" - ) - - # 设置过期时间戳 Cookie(用于前端判断) - china_now = get_china_now() - expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) - expire_at = int(expire_time.timestamp()) - + + _set_login_cookies(response, user.user_id) logger.info(f"✅ [登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") - - response.set_cookie( - key="session_expire_at", - value=str(expire_at), - max_age=max_age, - httponly=False, # 前端需要读取 - samesite="lax" - ) - + return LocalLoginResponse( success=True, message="登录成功", @@ -185,15 +399,215 @@ async def local_login(request: LocalLoginRequest, response: Response): ) +@router.post("/email/send-code") +async def send_email_verification_code(request: EmailSendCodeRequest): + """发送邮箱验证码(注册 / 登录 / 重置密码)""" + runtime = await _get_auth_runtime_settings() + if not runtime["email_auth_enabled"]: + raise HTTPException(status_code=403, detail="邮箱认证未启用") + + email = _validate_email(request.email) + scene = _validate_verification_scene(request.scene) + existing_user = await _find_user_by_email(email) + + if scene == "register": + if not runtime["email_register_enabled"]: + raise HTTPException(status_code=403, detail="邮箱注册未启用") + if existing_user: + raise HTTPException(status_code=400, detail="该邮箱已注册") + else: + if not existing_user: + raise HTTPException(status_code=404, detail="该邮箱尚未注册") + + if not runtime["smtp_host"] or not runtime["smtp_username"] or not runtime["smtp_password"]: + raise HTTPException(status_code=400, detail="系统 SMTP 未配置完整,暂无法发送验证码") + + now = get_china_now() + storage_key = _get_verification_storage_key(scene, email) + cached = _email_verification_storage.get(storage_key) + resend_interval = runtime["verification_resend_interval_seconds"] + ttl_minutes = runtime["verification_code_ttl_minutes"] + + if cached and cached["last_sent_at"] + timedelta(seconds=resend_interval) > now: + remain_seconds = int((cached["last_sent_at"] + timedelta(seconds=resend_interval) - now).total_seconds()) + raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请 {remain_seconds} 秒后重试") + + code = _generate_verification_code() + expires_at = now + timedelta(minutes=ttl_minutes) + subject, text_body, html_body = _build_verification_mail_content(scene, code, ttl_minutes) + from_email = runtime["smtp_from_email"] or runtime["smtp_username"] + + await email_service.send_mail( + host=runtime["smtp_host"], + port=runtime["smtp_port"], + username=runtime["smtp_username"], + password=runtime["smtp_password"], + use_tls=runtime["smtp_use_tls"], + use_ssl=runtime["smtp_use_ssl"], + from_email=from_email, + from_name=runtime["smtp_from_name"], + to_email=email, + subject=subject, + text_body=text_body, + html_body=html_body, + ) + + _email_verification_storage[storage_key] = { + "code": code, + "expires_at": expires_at, + "last_sent_at": now, + } + + logger.info(f"[邮箱验证码] 场景={scene} 已发送到 {email}") + return { + "success": True, + "message": "验证码已发送,请检查邮箱", + "expire_in_seconds": ttl_minutes * 60, + "resend_interval_seconds": resend_interval, + } + + +@router.post("/email/register", response_model=LocalLoginResponse) +async def email_register(request: EmailRegisterRequest, response: Response): + """邮箱验证码注册并自动登录""" + runtime = await _get_auth_runtime_settings() + if not runtime["email_auth_enabled"]: + raise HTTPException(status_code=403, detail="邮箱认证未启用") + if not runtime["email_register_enabled"]: + raise HTTPException(status_code=403, detail="邮箱注册未启用") + + email = _validate_email(request.email) + code = request.code.strip() + _validate_password(request.password) + + if len(code) != 6 or not code.isdigit(): + raise HTTPException(status_code=400, detail="请输入6位数字验证码") + + cached = _email_verification_storage.get(_get_verification_storage_key("register", email)) + if not cached: + raise HTTPException(status_code=400, detail="请先发送验证码") + + now = get_china_now() + if cached["expires_at"] < now: + _email_verification_storage.pop(_get_verification_storage_key("register", email), None) + raise HTTPException(status_code=400, detail="验证码已过期,请重新发送") + + if cached["code"] != code: + raise HTTPException(status_code=400, detail="验证码错误") + + existing_user = await _find_user_by_email(email) + if existing_user: + _email_verification_storage.pop(_get_verification_storage_key("register", email), None) + raise HTTPException(status_code=400, detail="该邮箱已注册") + + user = await _create_email_user(email, request.display_name) + await password_manager.set_password(user.user_id, email, request.password) + _email_verification_storage.pop(_get_verification_storage_key("register", email), None) + + _set_login_cookies(response, user.user_id) + logger.info(f"✅ [邮箱注册] 用户 {user.user_id} 注册并登录成功") + + return LocalLoginResponse( + success=True, + message="注册成功", + user=user.dict() + ) + + +@router.post("/email/login", response_model=LocalLoginResponse) +async def email_login(request: EmailLoginRequest, response: Response): + """邮箱验证码登录""" + runtime = await _get_auth_runtime_settings() + if not runtime["email_auth_enabled"]: + raise HTTPException(status_code=403, detail="邮箱认证未启用") + + email = _validate_email(request.email) + code = request.code.strip() + user = await _find_user_by_email(email) + if not user: + raise HTTPException(status_code=404, detail="该邮箱尚未注册") + + if len(code) != 6 or not code.isdigit(): + raise HTTPException(status_code=400, detail="请输入6位数字验证码") + + storage_key = _get_verification_storage_key("login", email) + cached = _email_verification_storage.get(storage_key) + if not cached: + raise HTTPException(status_code=400, detail="请先发送登录验证码") + + now = get_china_now() + if cached["expires_at"] < now: + _email_verification_storage.pop(storage_key, None) + raise HTTPException(status_code=400, detail="登录验证码已过期,请重新发送") + + if cached["code"] != code: + raise HTTPException(status_code=400, detail="登录验证码错误") + + _email_verification_storage.pop(storage_key, None) + await _touch_user_last_login(user.user_id) + latest_user = await user_manager.get_user(user.user_id) + if latest_user: + user = latest_user + + _set_login_cookies(response, user.user_id) + logger.info(f"✅ [邮箱登录] 用户 {user.user_id} 登录成功") + + return LocalLoginResponse( + success=True, + message="登录成功", + user=user.dict() + ) + + +@router.post("/email/reset-password") +async def email_reset_password(request: EmailResetPasswordRequest): + """通过邮箱验证码重置密码""" + runtime = await _get_auth_runtime_settings() + if not runtime["email_auth_enabled"]: + raise HTTPException(status_code=403, detail="邮箱认证未启用") + + email = _validate_email(request.email) + code = request.code.strip() + _validate_password(request.new_password) + + user = await _find_user_by_email(email) + if not user: + raise HTTPException(status_code=404, detail="该邮箱尚未注册") + + if len(code) != 6 or not code.isdigit(): + raise HTTPException(status_code=400, detail="请输入6位数字验证码") + + storage_key = _get_verification_storage_key("reset_password", email) + cached = _email_verification_storage.get(storage_key) + if not cached: + raise HTTPException(status_code=400, detail="请先发送重置密码验证码") + + now = get_china_now() + if cached["expires_at"] < now: + _email_verification_storage.pop(storage_key, None) + raise HTTPException(status_code=400, detail="重置密码验证码已过期,请重新发送") + + if cached["code"] != code: + raise HTTPException(status_code=400, detail="重置密码验证码错误") + + await password_manager.set_password(user.user_id, email, request.new_password) + _email_verification_storage.pop(storage_key, None) + logger.info(f"✅ [邮箱重置密码] 用户 {user.user_id} 重置密码成功") + + return { + "success": True, + "message": "密码重置成功,请使用新验证码重新登录", + } + + @router.get("/linuxdo/url", response_model=AuthUrlResponse) async def get_linuxdo_auth_url(): """获取 LinuxDO 授权 URL""" state = oauth_service.generate_state() auth_url = oauth_service.get_authorization_url(state) - - # 临时存储 state(5分钟有效) + _state_storage[state] = True - + return AuthUrlResponse(auth_url=auth_url, state=state) @@ -205,43 +619,36 @@ async def _handle_callback( ): """ LinuxDO OAuth2 回调处理 - + 成功后重定向到前端首页,并设置 user_id Cookie """ - # 检查是否有错误 if error: raise HTTPException(status_code=400, detail=f"授权失败: {error}") - - # 检查必需参数 + if not code or not state: raise HTTPException(status_code=400, detail="缺少 code 或 state 参数") - - # 验证 state(防止 CSRF) + if state not in _state_storage: raise HTTPException(status_code=400, detail="无效的 state 参数") - - # 删除已使用的 state + del _state_storage[state] - - # 1. 使用 code 获取 access_token + token_data = await oauth_service.get_access_token(code) if not token_data or "access_token" not in token_data: raise HTTPException(status_code=400, detail="获取访问令牌失败") - + access_token = token_data["access_token"] - - # 2. 使用 access_token 获取用户信息 + user_info = await oauth_service.get_user_info(access_token) if not user_info: raise HTTPException(status_code=400, detail="获取用户信息失败") - - # 3. 创建或更新用户 + linuxdo_id = str(user_info.get("id")) username = user_info.get("username", "") display_name = user_info.get("name", username) avatar_url = user_info.get("avatar_url") trust_level = user_info.get("trust_level", 0) - + user = await user_manager.create_or_update_from_linuxdo( linuxdo_id=linuxdo_id, username=username, @@ -249,57 +656,29 @@ async def _handle_callback( avatar_url=avatar_url, trust_level=trust_level ) - - # 3.1. 检查是否是首次登录(没有密码记录) + is_first_login = not await password_manager.has_password(user.user_id) if is_first_login: logger.info(f"用户 {user.user_id} ({username}) 首次登录,需要初始化密码") - - # Settings 将在首次访问设置页面时自动创建(延迟初始化) - - # 4. 设置 Cookie 并重定向到前端回调页面 - # 使用配置的前端URL,支持不同的部署环境 + frontend_url = settings.FRONTEND_URL.rstrip('/') redirect_url = f"{frontend_url}/auth/callback" logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}") redirect_response = RedirectResponse(url=redirect_url) - - # 设置 httponly Cookie(2小时有效) - max_age = settings.SESSION_EXPIRE_MINUTES * 60 - redirect_response.set_cookie( - key="user_id", - value=user.user_id, - max_age=max_age, - httponly=True, - samesite="lax" - ) - - # 设置过期时间戳 Cookie(用于前端判断) - china_now = get_china_now() - expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) - expire_at = int(expire_time.timestamp()) - + + _set_login_cookies(redirect_response, user.user_id) logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") - - redirect_response.set_cookie( - key="session_expire_at", - value=str(expire_at), - max_age=max_age, - httponly=False, # 前端需要读取 - samesite="lax" - ) - - # 如果是首次登录,设置标记 Cookie(5分钟有效,仅用于前端显示初始密码提示) + if is_first_login: redirect_response.set_cookie( key="first_login", value="true", - max_age=300, # 5分钟有效 - httponly=False, # 前端需要读取 + max_age=300, + httponly=False, samesite="lax" ) logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 首次登录,已设置 first_login 标记") - + return redirect_response @@ -328,21 +707,18 @@ async def callback_alias( @router.post("/refresh") async def refresh_session(request: Request, response: Response): """刷新会话 - 延长登录状态""" - # 检查是否已登录 if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="未登录,无法刷新会话") - + user = request.state.user - - # 检查当前会话是否即将过期(剩余时间少于阈值) + session_expire_at = request.cookies.get("session_expire_at") if session_expire_at: try: expire_timestamp = int(session_expire_at) current_timestamp = int(get_china_now().timestamp()) remaining_minutes = (expire_timestamp - current_timestamp) / 60 - - # 如果剩余时间大于刷新阈值,不需要刷新 + if remaining_minutes > settings.SESSION_REFRESH_THRESHOLD_MINUTES: logger.info(f"⏱️ [刷新会话] 用户 {user.user_id} 会话仍有效,剩余 {int(remaining_minutes)} 分钟") return { @@ -351,37 +727,20 @@ async def refresh_session(request: Request, response: Response): "expire_at": expire_timestamp } except (ValueError, TypeError): - pass # Cookie 格式错误,继续刷新 - - # 刷新 Cookie - max_age = settings.SESSION_EXPIRE_MINUTES * 60 - response.set_cookie( - key="user_id", - value=user.user_id, - max_age=max_age, - httponly=True, - samesite="lax" - ) - - # 更新过期时间戳 + pass + + _set_login_cookies(response, user.user_id) + china_now = get_china_now() expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) expire_at = int(expire_time.timestamp()) - + logger.info(f"[刷新会话] 用户: {user.user_id}") logger.info(f"[刷新会话] 中国当前时间: {china_now.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)") logger.info(f"[刷新会话] 中国过期时间: {expire_time.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)") logger.info(f"[刷新会话] 过期时间戳 (秒): {expire_at}") - logger.info(f"[刷新会话] Cookie max_age (秒): {max_age}") - - response.set_cookie( - key="session_expire_at", - value=str(expire_at), - max_age=max_age, - httponly=False, - samesite="lax" - ) - + logger.info(f"[刷新会话] Cookie max_age (秒): {settings.SESSION_EXPIRE_MINUTES * 60}") + logger.info(f"用户 {user.user_id} 刷新会话成功") return { "message": "会话刷新成功", @@ -396,7 +755,7 @@ async def logout(request: Request, response: Response): user_id = getattr(request.state, 'user_id', None) if user_id: logger.info(f"🚪 [退出] 用户 {user_id} 退出登录") - + response.delete_cookie("user_id") response.delete_cookie("session_expire_at") return {"message": "退出登录成功"} @@ -407,7 +766,7 @@ async def get_current_user(request: Request): """获取当前登录用户信息""" if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="未登录") - + return request.state.user.dict() @@ -416,17 +775,16 @@ async def get_password_status(request: Request): """获取当前用户的密码状态""" if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="未登录") - + user = request.state.user has_password = await password_manager.has_password(user.user_id) has_custom = await password_manager.has_custom_password(user.user_id) username = await password_manager.get_username(user.user_id) - - # 如果使用默认密码,返回默认密码供用户查看 + default_password = None if has_password and not has_custom: default_password = f"{user.username}@666" - + return PasswordStatusResponse( has_password=has_password, has_custom_password=has_custom, @@ -440,17 +798,13 @@ async def set_user_password(request: Request, password_req: SetPasswordRequest): """设置当前用户的密码""" if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="未登录") - + user = request.state.user - - # 验证密码强度(至少6个字符) - if len(password_req.password) < 6: - raise HTTPException(status_code=400, detail="密码长度至少为6个字符") - - # 设置密码 + _validate_password(password_req.password) + await password_manager.set_password(user.user_id, user.username, password_req.password) logger.info(f"用户 {user.user_id} ({user.username}) 设置了自定义密码") - + return SetPasswordResponse( success=True, message="密码设置成功" @@ -461,26 +815,22 @@ async def set_user_password(request: Request, password_req: SetPasswordRequest): async def initialize_user_password(request: Request, password_req: SetPasswordRequest): """ 初始化首次登录用户的密码 - + 用于首次通过 Linux DO 授权登录的用户,可以选择设置自定义密码或使用默认密码 """ if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="未登录") - + user = request.state.user - - # 检查是否已经有密码(防止重复初始化) + if await password_manager.has_password(user.user_id): raise HTTPException(status_code=400, detail="密码已经初始化,请使用密码修改功能") - - # 验证密码强度(至少6个字符) - if len(password_req.password) < 6: - raise HTTPException(status_code=400, detail="密码长度至少为6个字符") - - # 设置密码 + + _validate_password(password_req.password) + await password_manager.set_password(user.user_id, user.username, password_req.password) logger.info(f"用户 {user.user_id} ({user.username}) 初始化密码成功") - + return SetPasswordResponse( success=True, message="密码初始化成功" @@ -490,69 +840,41 @@ async def initialize_user_password(request: Request, password_req: SetPasswordRe @router.post("/bind/login", response_model=LocalLoginResponse) async def bind_account_login(request: LocalLoginRequest, response: Response): """使用绑定的账号密码登录(LinuxDO授权后绑定的账号)""" - # 查找用户 all_users = await user_manager.get_all_users() target_user = None - + logger.info(f"[绑定账号登录] 尝试登录用户名: {request.username}") logger.info(f"[绑定账号登录] 当前共有 {len(all_users)} 个用户") - + for user in all_users: - # 同时检查 users 表的 username 和 user_passwords 表的 username password_username = await password_manager.get_username(user.user_id) logger.info(f"[绑定账号登录] 检查用户 {user.user_id}: users.username={user.username}, passwords.username={password_username}") - + if user.username == request.username or password_username == request.username: target_user = user logger.info(f"[绑定账号登录] 找到匹配用户: {user.user_id}") break - + if not target_user: logger.warning(f"[绑定账号登录] 用户名 {request.username} 未找到") raise HTTPException(status_code=401, detail="用户名或密码错误") - - # 检查是否有密码记录 + has_pwd = await password_manager.has_password(target_user.user_id) if not has_pwd: logger.warning(f"[绑定账号登录] 用户 {target_user.user_id} 没有设置密码") raise HTTPException(status_code=401, detail="用户名或密码错误") - - # 验证密码 + is_valid = await password_manager.verify_password(target_user.user_id, request.password) logger.info(f"[绑定账号登录] 用户 {target_user.user_id} 密码验证结果: {is_valid}") - + if not is_valid: raise HTTPException(status_code=401, detail="用户名或密码错误") - - # Settings 将在首次访问设置页面时自动创建(延迟初始化) - - # 设置 Cookie(2小时有效) - max_age = settings.SESSION_EXPIRE_MINUTES * 60 - response.set_cookie( - key="user_id", - value=target_user.user_id, - max_age=max_age, - httponly=True, - samesite="lax" - ) - - # 设置过期时间戳 Cookie(用于前端判断) - china_now = get_china_now() - expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) - expire_at = int(expire_time.timestamp()) - + + _set_login_cookies(response, target_user.user_id) logger.info(f"✅ [绑定账号登录] 用户 {target_user.user_id} ({request.username}) 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") - - response.set_cookie( - key="session_expire_at", - value=str(expire_at), - max_age=max_age, - httponly=False, # 前端需要读取 - samesite="lax" - ) - + return LocalLoginResponse( success=True, message="登录成功", user=target_user.dict() - ) \ No newline at end of file + ) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index f9d66b5..d33b2e7 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -18,12 +18,14 @@ from app.services.cover_generation_service import cover_generation_service from app.schemas.settings import ( SettingsCreate, SettingsUpdate, SettingsResponse, APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest, - PresetUpdateRequest, PresetResponse, PresetListResponse + PresetUpdateRequest, PresetResponse, PresetListResponse, + SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest ) from app.user_manager import User from app.logger import get_logger from app.config import settings as app_settings, PROJECT_ROOT from app.services.ai_service import AIService, create_user_ai_service, create_user_ai_service_with_mcp, normalize_provider +from app.services.email_service import email_service logger = get_logger(__name__) @@ -56,6 +58,46 @@ def require_login(request: Request): return request.state.user +def require_admin(user: User = Depends(require_login)): + """依赖:要求管理员权限""" + if not user.is_admin: + raise HTTPException(status_code=403, detail="仅管理员可访问系统设置") + return user + + +async def get_or_create_admin_settings(db: AsyncSession, user: User) -> Settings: + """获取或创建管理员设置,系统级 SMTP 配置挂在管理员设置记录上""" + result = await db.execute( + select(Settings).where(Settings.user_id == user.user_id) + ) + settings = result.scalar_one_or_none() + + if not settings: + env_defaults = read_env_defaults() + settings = Settings( + user_id=user.user_id, + smtp_provider=app_settings.SMTP_PROVIDER, + smtp_host=app_settings.SMTP_HOST, + smtp_port=app_settings.SMTP_PORT, + smtp_username=app_settings.SMTP_USERNAME, + smtp_password=app_settings.SMTP_PASSWORD, + smtp_use_tls=app_settings.SMTP_USE_TLS, + smtp_use_ssl=app_settings.SMTP_USE_SSL, + smtp_from_email=app_settings.SMTP_FROM_EMAIL, + smtp_from_name=app_settings.SMTP_FROM_NAME, + email_auth_enabled=app_settings.EMAIL_AUTH_ENABLED, + email_register_enabled=app_settings.EMAIL_REGISTER_ENABLED, + verification_code_ttl_minutes=app_settings.EMAIL_VERIFICATION_CODE_TTL_MINUTES, + verification_resend_interval_seconds=app_settings.EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS, + **env_defaults + ) + db.add(settings) + await db.commit() + await db.refresh(settings) + + return settings + + async def get_user_ai_service( user: User = Depends(require_login), db: AsyncSession = Depends(get_db) @@ -169,6 +211,115 @@ async def test_cover_settings( } +@router.get("/system/smtp", response_model=SystemSMTPSettingsResponse) +async def get_system_smtp_settings( + user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db) +): + """获取系统 SMTP 设置(仅管理员)""" + settings = await get_or_create_admin_settings(db, user) + return settings + + +@router.put("/system/smtp", response_model=SystemSMTPSettingsResponse) +async def update_system_smtp_settings( + data: SystemSMTPSettingsUpdate, + user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db) +): + """更新系统 SMTP 设置(仅管理员)""" + settings = await get_or_create_admin_settings(db, user) + update_data = data.model_dump(exclude_unset=True) + + if update_data.get("smtp_provider") == "qq": + update_data.setdefault("smtp_host", "smtp.qq.com") + update_data.setdefault("smtp_port", 465) + update_data.setdefault("smtp_use_ssl", True) + update_data.setdefault("smtp_use_tls", False) + + if update_data.get("smtp_use_ssl") and update_data.get("smtp_use_tls"): + raise HTTPException(status_code=400, detail="SSL 和 TLS 不能同时启用") + + for key, value in update_data.items(): + setattr(settings, key, value) + + await db.commit() + await db.refresh(settings) + logger.info(f"管理员 {user.user_id} 更新系统 SMTP 设置") + return settings + + +@router.post("/system/smtp/test") +async def test_system_smtp_settings( + data: SMTPTestRequest, + user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db) +): + """测试系统 SMTP 设置(真实发送测试邮件)""" + settings = await get_or_create_admin_settings(db, user) + + if not settings.smtp_host or not settings.smtp_username or not settings.smtp_password: + raise HTTPException(status_code=400, detail="请先完善 SMTP 主机、用户名和授权码") + + if settings.smtp_provider == "qq" and settings.smtp_host != "smtp.qq.com": + raise HTTPException(status_code=400, detail="QQ 邮箱 SMTP 主机必须为 smtp.qq.com") + + if "@" not in data.to_email or "." not in data.to_email.split("@")[-1]: + raise HTTPException(status_code=400, detail="测试收件邮箱格式不正确") + + from_email = settings.smtp_from_email or settings.smtp_username + if not from_email: + raise HTTPException(status_code=400, detail="请先配置发件人邮箱或 SMTP 用户名") + + subject = "MuMuAINovel SMTP 测试邮件" + text_body = ( + "这是一封来自 MuMuAINovel 系统设置页面的 SMTP 测试邮件。\n\n" + f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" + f"SMTP 服务商:{settings.smtp_provider}\n" + f"SMTP 主机:{settings.smtp_host}:{settings.smtp_port}\n" + "如果你收到这封邮件,说明当前 SMTP 配置可正常发送邮件。" + ) + html_body = f""" +
+

MuMuAINovel SMTP 测试邮件

+

这是一封来自系统设置页面的 SMTP 测试邮件。

+ +

如果你收到这封邮件,说明当前 SMTP 配置可正常发送邮件。

+
+ """ + + try: + await email_service.send_mail( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=settings.smtp_password, + use_tls=settings.smtp_use_tls, + use_ssl=settings.smtp_use_ssl, + from_email=from_email, + from_name=settings.smtp_from_name, + to_email=data.to_email, + subject=subject, + text_body=text_body, + html_body=html_body, + ) + except Exception as exc: + logger.exception(f"SMTP 测试邮件发送失败: {exc}") + raise HTTPException(status_code=400, detail=f"SMTP 测试邮件发送失败: {str(exc)}") from exc + + return { + "success": True, + "message": f"测试邮件已发送至 {data.to_email},请检查收件箱和垃圾箱", + "provider": settings.smtp_provider, + "host": settings.smtp_host, + "port": settings.smtp_port, + } + + @router.post("", response_model=SettingsResponse) async def save_settings( data: SettingsCreate, diff --git a/backend/app/config.py b/backend/app/config.py index 646c7f5..9a3e117 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -106,6 +106,21 @@ class Settings(BaseSettings): # 会话配置 SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时 SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新 + + # 系统 SMTP 默认配置(可被管理员系统设置覆盖) + SMTP_PROVIDER: str = "qq" + SMTP_HOST: Optional[str] = "smtp.qq.com" + SMTP_PORT: int = 465 + SMTP_USERNAME: Optional[str] = None + SMTP_PASSWORD: Optional[str] = None + SMTP_USE_TLS: bool = False + SMTP_USE_SSL: bool = True + SMTP_FROM_EMAIL: Optional[str] = None + SMTP_FROM_NAME: str = "MuMuAINovel" + EMAIL_AUTH_ENABLED: bool = True + EMAIL_REGISTER_ENABLED: bool = True + EMAIL_VERIFICATION_CODE_TTL_MINUTES: int = 10 + EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS: int = 60 # 提示词工坊配置 WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器 diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py index 4923bc4..bf12d5e 100644 --- a/backend/app/models/settings.py +++ b/backend/app/models/settings.py @@ -26,6 +26,21 @@ class Settings(Base): cover_image_model = Column(String(100), comment="封面图片模型名称") cover_enabled = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用封面图片生成") + # 系统级 SMTP 配置(仅管理员维护) + smtp_provider = Column(String(50), default="qq", server_default="qq", nullable=False, comment="SMTP 提供商") + smtp_host = Column(String(255), comment="SMTP 主机") + smtp_port = Column(Integer, default=465, server_default="465", nullable=False, comment="SMTP 端口") + smtp_username = Column(String(255), comment="SMTP 用户名") + smtp_password = Column(String(500), comment="SMTP 密码或授权码") + smtp_use_tls = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用 TLS") + smtp_use_ssl = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用 SSL") + smtp_from_email = Column(String(255), comment="发件人邮箱") + smtp_from_name = Column(String(255), default="MuMuAINovel", server_default="MuMuAINovel", nullable=False, comment="发件人名称") + email_auth_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱认证") + email_register_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱注册") + verification_code_ttl_minutes = Column(Integer, default=10, server_default="10", nullable=False, comment="验证码有效期(分钟)") + verification_resend_interval_seconds = Column(Integer, default=60, server_default="60", nullable=False, comment="验证码重发间隔(秒)") + preferences = Column(Text, comment="其他偏好设置(JSON)") created_at = Column(DateTime, server_default=func.now(), comment="创建时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index f4ace23..1b40369 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -43,6 +43,47 @@ class SettingsResponse(SettingsBase): updated_at: datetime +class SystemSMTPSettingsBase(BaseModel): + """系统 SMTP 设置基础模型""" + model_config = ConfigDict(protected_namespaces=()) + + smtp_provider: str = Field(default="qq", description="SMTP 提供商") + smtp_host: Optional[str] = Field(default=None, description="SMTP 主机") + smtp_port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口") + smtp_username: Optional[str] = Field(default=None, description="SMTP 用户名") + smtp_password: Optional[str] = Field(default=None, description="SMTP 密码或授权码") + smtp_use_tls: bool = Field(default=False, description="是否启用 TLS") + smtp_use_ssl: bool = Field(default=True, description="是否启用 SSL") + smtp_from_email: Optional[str] = Field(default=None, description="发件人邮箱") + smtp_from_name: str = Field(default="MuMuAINovel", description="发件人名称") + email_auth_enabled: bool = Field(default=True, description="是否启用邮箱认证") + email_register_enabled: bool = Field(default=True, description="是否启用邮箱注册") + verification_code_ttl_minutes: int = Field(default=10, ge=1, le=120, description="验证码有效期(分钟)") + verification_resend_interval_seconds: int = Field(default=60, ge=10, le=3600, description="验证码重发间隔(秒)") + + +class SystemSMTPSettingsUpdate(SystemSMTPSettingsBase): + """系统 SMTP 设置更新模型""" + pass + + +class SystemSMTPSettingsResponse(SystemSMTPSettingsBase): + """系统 SMTP 设置响应模型""" + model_config = ConfigDict(from_attributes=True, protected_namespaces=()) + + id: str + user_id: str + created_at: datetime + updated_at: datetime + + +class SMTPTestRequest(BaseModel): + """SMTP 测试请求模型""" + model_config = ConfigDict(protected_namespaces=()) + + to_email: str = Field(..., min_length=3, max_length=255, description="测试收件邮箱") + + # ========== API配置预设相关模型 ========== class APIKeyPresetConfig(BaseModel): diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..aaa3bd5 --- /dev/null +++ b/backend/app/services/email_service.py @@ -0,0 +1,71 @@ +"""SMTP 邮件发送服务""" +from __future__ import annotations + +from email.message import EmailMessage +from typing import Optional + +import aiosmtplib + +from app.logger import get_logger + +logger = get_logger(__name__) + + +class EmailService: + """系统 SMTP 邮件发送服务""" + + async def send_mail( + self, + *, + host: str, + port: int, + username: str, + password: str, + use_tls: bool, + use_ssl: bool, + from_email: str, + from_name: str, + to_email: str, + subject: str, + text_body: str, + html_body: Optional[str] = None, + ) -> None: + if use_tls and use_ssl: + raise ValueError("SMTP 配置错误:TLS 和 SSL 不能同时启用") + + message = EmailMessage() + message['From'] = f'{from_name} <{from_email}>' if from_name else from_email + message['To'] = to_email + message['Subject'] = subject + message.set_content(text_body) + if html_body: + message.add_alternative(html_body, subtype='html') + + logger.info(f"[SMTP] 准备发送测试邮件到 {self._mask_email(to_email)},服务器: {host}:{port}") + + await aiosmtplib.send( + message, + hostname=host, + port=port, + username=username, + password=password, + use_tls=use_ssl, + start_tls=use_tls, + timeout=20, + ) + + logger.info(f"[SMTP] 测试邮件发送成功: {self._mask_email(to_email)}") + + @staticmethod + def _mask_email(email: str) -> str: + if '@' not in email: + return email + name, domain = email.split('@', 1) + if len(name) <= 2: + masked_name = name[0] + '*' + else: + masked_name = name[0] + '*' * (len(name) - 2) + name[-1] + return f"{masked_name}@{domain}" + + +email_service = EmailService() diff --git a/backend/requirements.txt b/backend/requirements.txt index f877fe9..f4dec26 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,6 +22,7 @@ anthropic==0.72.0 httpx==0.28.1 python-dotenv==1.1.0 psutil==6.1.1 +aiosmtplib==4.0.2 # MCP官方库(Model Context Protocol Python SDK) mcp==1.22.0 diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index d6573e5..8a1c538 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,46 +1,151 @@ -import { useEffect, useState } from 'react'; -import { Alert, Button, Card, Col, Divider, Form, Input, Layout, Row, Space, Spin, Tag, Typography, message, theme } from 'antd'; -import { BookOutlined, LockOutlined, RobotOutlined, SafetyCertificateOutlined, TeamOutlined, ThunderboltOutlined, UserOutlined } from '@ant-design/icons'; +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + Col, + Divider, + Form, + Input, + Layout, + Row, + Space, + Spin, + Tabs, + Tag, + Typography, + message, + theme, +} from 'antd'; +import { + BookOutlined, + LockOutlined, + MailOutlined, + RobotOutlined, + SafetyCertificateOutlined, + TeamOutlined, + ThunderboltOutlined, + UserOutlined, +} from '@ant-design/icons'; import { authApi } from '../services/api'; import { useNavigate, useSearchParams } from 'react-router-dom'; import AnnouncementModal from '../components/AnnouncementModal'; - import ThemeSwitch from '../components/ThemeSwitch'; -const { Title, Paragraph } = Typography; +const { Title, Paragraph, Text } = Typography; + +interface AuthConfig { + local_auth_enabled: boolean; + linuxdo_enabled: boolean; + email_auth_enabled: boolean; + email_register_enabled: boolean; +} + +interface LocalLoginValues { + username: string; + password: string; +} + +interface EmailLoginValues { + email: string; + code: string; +} + +interface EmailRegisterValues { + email: string; + code: string; + password: string; + confirmPassword: string; + display_name?: string; +} + +interface ResetPasswordValues { + email: string; + code: string; + new_password: string; + confirmNewPassword: string; +} export default function Login() { const navigate = useNavigate(); const [searchParams] = useSearchParams(); const [loading, setLoading] = useState(false); const [checking, setChecking] = useState(true); - const [localAuthEnabled, setLocalAuthEnabled] = useState(false); - const [linuxdoEnabled, setLinuxdoEnabled] = useState(false); - const [form] = Form.useForm(); + const [authConfig, setAuthConfig] = useState({ + local_auth_enabled: false, + linuxdo_enabled: false, + email_auth_enabled: false, + email_register_enabled: false, + }); + const [localForm] = Form.useForm(); + const [emailLoginForm] = Form.useForm(); + const [emailRegisterForm] = Form.useForm(); + const [resetPasswordForm] = Form.useForm(); const { token } = theme.useToken(); const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`; const primaryButtonShadow = `0 8px 20px ${alphaColor(token.colorPrimary, 0.28)}`; const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`; const [showAnnouncement, setShowAnnouncement] = useState(false); + const [loginCodeSending, setLoginCodeSending] = useState(false); + const [registerCodeSending, setRegisterCodeSending] = useState(false); + const [resetCodeSending, setResetCodeSending] = useState(false); + const [loginCountdown, setLoginCountdown] = useState(0); + const [registerCountdown, setRegisterCountdown] = useState(0); + const [resetCountdown, setResetCountdown] = useState(0); + const [showResetPassword, setShowResetPassword] = useState(false); + + const localAuthEnabled = authConfig.local_auth_enabled; + const linuxdoEnabled = authConfig.linuxdo_enabled; + const emailAuthEnabled = authConfig.email_auth_enabled; + const emailRegisterEnabled = authConfig.email_register_enabled; + + useEffect(() => { + const timers = [ + { value: loginCountdown, setter: setLoginCountdown }, + { value: registerCountdown, setter: setRegisterCountdown }, + { value: resetCountdown, setter: setResetCountdown }, + ].map(({ value, setter }) => { + if (value <= 0) { + return null; + } + + return window.setInterval(() => { + setter((prev) => { + if (prev <= 1) { + return 0; + } + return prev - 1; + }); + }, 1000); + }); + + return () => { + timers.forEach((timer) => { + if (timer) { + window.clearInterval(timer); + } + }); + }; + }, [loginCountdown, registerCountdown, resetCountdown]); - // 检查是否已登录和获取认证配置 useEffect(() => { const checkAuth = async () => { try { await authApi.getCurrentUser(); - // 已登录,重定向到首页 const redirect = searchParams.get('redirect') || '/'; navigate(redirect); } catch { - // 未登录,获取认证配置 try { const config = await authApi.getAuthConfig(); - setLocalAuthEnabled(config.local_auth_enabled); - setLinuxdoEnabled(config.linuxdo_enabled); + setAuthConfig(config); } catch (error) { console.error('获取认证配置失败:', error); - // 默认显示LinuxDO登录 - setLinuxdoEnabled(true); + setAuthConfig({ + local_auth_enabled: false, + linuxdo_enabled: true, + email_auth_enabled: false, + email_register_enabled: false, + }); } setChecking(false); } @@ -48,29 +153,131 @@ export default function Login() { checkAuth(); }, [navigate, searchParams]); - const handleLocalLogin = async (values: { username: string; password: string }) => { + const handleLoginSuccess = () => { + message.success('登录成功!'); + + const hideForever = localStorage.getItem('announcement_hide_forever'); + const hideToday = localStorage.getItem('announcement_hide_today'); + const today = new Date().toDateString(); + + if (hideForever === 'true' || hideToday === today) { + const redirect = searchParams.get('redirect') || '/'; + navigate(redirect); + } else { + setShowAnnouncement(true); + } + }; + + const handleLocalLogin = async (values: LocalLoginValues) => { try { setLoading(true); const response = await authApi.localLogin(values.username, values.password); - if (response.success) { - message.success('登录成功!'); - - // 检查是否永久隐藏公告 - const hideForever = localStorage.getItem('announcement_hide_forever'); - const hideToday = localStorage.getItem('announcement_hide_today'); - const today = new Date().toDateString(); - - // 如果永久隐藏或今日已隐藏,则不显示公告 - if (hideForever === 'true' || hideToday === today) { - const redirect = searchParams.get('redirect') || '/'; - navigate(redirect); - } else { - setShowAnnouncement(true); - } + handleLoginSuccess(); } } catch (error) { console.error('本地登录失败:', error); + } finally { + setLoading(false); + } + }; + + const handleEmailLogin = async (values: EmailLoginValues) => { + try { + setLoading(true); + const response = await authApi.emailLogin({ + email: values.email, + code: values.code, + }); + if (response.success) { + handleLoginSuccess(); + } + } catch (error) { + console.error('邮箱验证码登录失败:', error); + } finally { + setLoading(false); + } + }; + + const sendLoginCode = async () => { + try { + const values = await emailLoginForm.validateFields(['email']); + setLoginCodeSending(true); + const result = await authApi.sendEmailCode({ email: values.email, scene: 'login' }); + message.success(result.message || '验证码已发送'); + setLoginCountdown(result.resend_interval_seconds || 60); + } catch (error) { + console.error('发送 login 验证码失败:', error); + } finally { + setLoginCodeSending(false); + } + }; + + const sendRegisterCode = async () => { + try { + const values = await emailRegisterForm.validateFields(['email']); + setRegisterCodeSending(true); + const result = await authApi.sendEmailCode({ email: values.email, scene: 'register' }); + message.success(result.message || '验证码已发送'); + setRegisterCountdown(result.resend_interval_seconds || 60); + } catch (error) { + console.error('发送 register 验证码失败:', error); + } finally { + setRegisterCodeSending(false); + } + }; + + const sendResetCode = async () => { + try { + const values = await resetPasswordForm.validateFields(['email']); + setResetCodeSending(true); + const result = await authApi.sendEmailCode({ email: values.email, scene: 'reset_password' }); + message.success(result.message || '验证码已发送'); + setResetCountdown(result.resend_interval_seconds || 60); + } catch (error) { + console.error('发送 reset_password 验证码失败:', error); + } finally { + setResetCodeSending(false); + } + }; + + const handleEmailRegister = async (values: EmailRegisterValues) => { + try { + setLoading(true); + const response = await authApi.emailRegister({ + email: values.email, + code: values.code, + password: values.password, + display_name: values.display_name?.trim() || undefined, + }); + if (response.success) { + message.success('注册成功,已自动登录'); + emailRegisterForm.resetFields(['code', 'password', 'confirmPassword']); + setRegisterCountdown(0); + handleLoginSuccess(); + } + } catch (error) { + console.error('邮箱注册失败:', error); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async (values: ResetPasswordValues) => { + try { + setLoading(true); + const result = await authApi.resetEmailPassword({ + email: values.email, + code: values.code, + new_password: values.new_password, + }); + message.success(result.message || '密码重置成功'); + resetPasswordForm.resetFields(['code', 'new_password', 'confirmNewPassword']); + setResetCountdown(0); + setShowResetPassword(false); + } catch (error) { + console.error('重置密码失败:', error); + } finally { setLoading(false); } }; @@ -80,13 +287,11 @@ export default function Login() { setLoading(true); const response = await authApi.getLinuxDOAuthUrl(); - // 保存重定向地址到 sessionStorage const redirect = searchParams.get('redirect'); if (redirect) { sessionStorage.setItem('login_redirect', redirect); } - // 跳转到 LinuxDO 授权页面 window.location.href = response.auth_url; } catch (error) { console.error('获取授权地址失败:', error); @@ -95,53 +300,397 @@ export default function Login() { } }; - if (checking) { - return ( -
- -
- ); - } + const handleAnnouncementClose = () => { + setShowAnnouncement(false); + const redirect = searchParams.get('redirect') || '/'; + navigate(redirect); + }; + + const handleDoNotShowToday = () => { + const today = new Date().toDateString(); + localStorage.setItem('announcement_hide_today', today); + }; + + const handleNeverShow = () => { + localStorage.setItem('announcement_hide_forever', 'true'); + }; + + const loginTips = useMemo(() => { + const tips = [ + '首次 LinuxDO 登录会自动创建账号。', + ]; + + if (localAuthEnabled) { + tips.unshift('本地登录默认账号:admin / admin123'); + } + + if (emailAuthEnabled) { + tips.push('邮箱注册用户支持通过邮箱验证码重置密码。'); + } + + return tips; + }, [emailAuthEnabled, localAuthEnabled]); + + const featureItems = [ + { + icon: , + title: '多 AI 模型协同', + description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。', + }, + { + icon: , + title: '智能向导驱动', + description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。', + }, + { + icon: , + title: '角色组织管理', + description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。', + }, + { + icon: , + title: '章节创作闭环', + description: '支持章节生成、编辑、重写与润色,持续提升内容质量。', + }, + ]; - // 渲染本地登录表单 const renderLocalLogin = () => ( + <> +
+ + } + placeholder="请输入管理账号/邮箱" + autoComplete="username" + style={{ height: 46, borderRadius: 12 }} + /> + + + } + placeholder="请输入访问密钥" + autoComplete="current-password" + style={{ height: 46, borderRadius: 12 }} + /> + + + + +
+ + {linuxdoEnabled ? ( + <> + 第三方登录 + {renderLinuxDOLogin()} + + ) : null} + + ); + + const renderEmailLogin = () => { + if (showResetPassword) { + return ( +
+ + + 忘记密码 / 重置密码 + + + + +
+ + } placeholder="请输入注册邮箱" /> + + + + + + + + + + + } placeholder="请输入新密码" /> + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的新密码不一致')); + }, + }), + ]} + > + } placeholder="请再次输入新密码" /> + + +
+
+
+
+ ); + } + + return ( +
+ + } + placeholder="请输入已注册邮箱" + autoComplete="email" + style={{ height: 46, borderRadius: 12 }} + /> + + + + + + } + placeholder="请输入 6 位登录验证码" + maxLength={6} + style={{ height: 46, borderRadius: '12px 0 0 12px' }} + /> + + + + + + + + + +
+ +
+
+ ); + }; + + const renderEmailRegister = () => (
+ } + placeholder="请输入注册邮箱" + autoComplete="email" + style={{ height: 46, borderRadius: 12 }} + /> + + + + + + } + placeholder="请输入 6 位验证码" + maxLength={6} + style={{ height: 46, borderRadius: '12px 0 0 12px' }} + /> + + + + + + } - placeholder="请输入管理账号" - autoComplete="username" + placeholder="选填,默认使用邮箱前缀" + autoComplete="nickname" style={{ height: 46, borderRadius: 12 }} /> + } - placeholder="请输入访问密钥" - autoComplete="current-password" + placeholder="请输入登录密码" + autoComplete="new-password" style={{ height: 46, borderRadius: 12 }} /> + + ({ + validator(_, value) { + if (!value || getFieldValue('password') === value) { + return Promise.resolve(); + } + return Promise.reject(new Error('两次输入的密码不一致')); + }, + }), + ]} + > + } + placeholder="请再次输入登录密码" + autoComplete="new-password" + style={{ height: 46, borderRadius: 12 }} + /> + + + + + 验证码将发送到你填写的邮箱,若未收到请检查垃圾箱或稍后重试。注册后可通过邮箱验证码登录,也支持邮箱重置密码。 +
); - // 渲染LinuxDO登录 const renderLinuxDOLogin = () => (
); - const handleAnnouncementClose = () => { - setShowAnnouncement(false); - const redirect = searchParams.get('redirect') || '/'; - navigate(redirect); - }; - - const handleDoNotShowToday = () => { - // 设置今日不再显示 - const today = new Date().toDateString(); - localStorage.setItem('announcement_hide_today', today); - }; - - const handleNeverShow = () => { - // 设置永久不再显示 - localStorage.setItem('announcement_hide_forever', 'true'); - }; - - const loginTips = [ - '本地登录默认账号:admin / admin123', - '首次 LinuxDO 登录会自动创建账号', - '系统采用多用户数据隔离机制,每位用户拥有独立的创作空间与配置。', + const authTabs = [ + ...(localAuthEnabled + ? [ + { + key: 'local-login', + label: '本地登录', + children: renderLocalLogin(), + }, + ] + : []), + ...(emailAuthEnabled + ? [ + { + key: 'email-login', + label: '邮箱登录', + children: renderEmailLogin(), + }, + ] + : []), + ...(emailAuthEnabled && emailRegisterEnabled + ? [ + { + key: 'email-register', + label: '邮箱注册', + children: renderEmailRegister(), + }, + ] + : []), ]; - const featureItems = [ - { - icon: , - title: '多 AI 模型协同', - description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。', - }, - { - icon: , - title: '智能向导驱动', - description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。', - }, - { - icon: , - title: '角色组织管理', - description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。', - }, - { - icon: , - title: '章节创作闭环', - description: '支持章节生成、编辑、重写与润色,持续提升内容质量。', - }, - ]; + if (checking) { + return ( +
+ +
+ ); + } return ( <> @@ -313,7 +865,6 @@ export default function Login() { justifyContent: 'space-between', gap: 34, width: '100%', - // flex: 1, }} > @@ -444,7 +995,7 @@ export default function Login() { background: token.colorBgLayout, }} > -
+
欢迎回来 @@ -455,23 +1006,26 @@ export default function Login() { </Space> <div style={{ marginTop: 22 }}> - {localAuthEnabled ? renderLocalLogin() : null} - - {linuxdoEnabled && localAuthEnabled ? ( - <> - <Divider style={{ margin: '18px 0 16px' }}>或</Divider> - {renderLinuxDOLogin()} - </> + {authTabs.length > 0 ? ( + <Tabs defaultActiveKey={authTabs[0].key} items={authTabs} /> ) : null} - {!localAuthEnabled && linuxdoEnabled ? renderLinuxDOLogin() : null} - - {!localAuthEnabled && !linuxdoEnabled ? ( + {!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? ( <Alert type="warning" showIcon message="当前未启用可用登录方式" - description="请联系管理员在系统配置中启用本地登录或 LinuxDO OAuth 登录。" + description="请联系管理员在系统配置中启用本地登录、邮箱认证或 LinuxDO OAuth 登录。" + /> + ) : null} + + {emailAuthEnabled && !emailRegisterEnabled ? ( + <Alert + type="info" + showIcon + style={{ marginTop: 12, borderRadius: 12 }} + message="邮箱注册暂未开放" + description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。" /> ) : null} @@ -500,4 +1054,4 @@ export default function Login() { </Layout> </> ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/ProjectList.tsx b/frontend/src/pages/ProjectList.tsx index 0cb78b1..8060a18 100644 --- a/frontend/src/pages/ProjectList.tsx +++ b/frontend/src/pages/ProjectList.tsx @@ -1,18 +1,19 @@ import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { Card, Button, Modal, message, Spin, Space, Tag, Typography, Upload, Checkbox, Tooltip, Drawer, Menu, theme } from 'antd'; -import { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined } from '@ant-design/icons'; -import { projectApi } from '../services/api'; +import { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined, MailOutlined } from '@ant-design/icons'; +import { authApi, projectApi } from '../services/api'; import { useStore } from '../store'; import { useProjectSync } from '../store/hooks'; import { eventBus, EventNames } from '../store/eventBus'; import type { ReactNode } from 'react'; -import type { Project } from '../types'; +import type { Project, User } from '../types'; import UserMenu from '../components/UserMenu'; import ChangelogFloatingButton from '../components/ChangelogFloatingButton'; import ThemeSwitch from '../components/ThemeSwitch'; import { useThemeMode } from '../theme/useThemeMode'; import SettingsPage from './Settings'; +import SystemSettingsPage from './SystemSettings'; import MCPPluginsPage from './MCPPlugins'; import PromptTemplates from './PromptTemplates'; import BookImport from './BookImport'; @@ -41,11 +42,11 @@ const formatWordCount = (count: number): string => { } }; -type ProjectListView = 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'; +type ProjectListView = 'projects' | 'settings' | 'system-settings' | 'mcp' | 'prompts' | 'book-import'; const parseViewFromSearch = (search: string): ProjectListView => { const view = new URLSearchParams(search).get('view'); - if (view === 'settings' || view === 'mcp' || view === 'prompts' || view === 'book-import' || view === 'projects') { + if (view === 'settings' || view === 'system-settings' || view === 'mcp' || view === 'prompts' || view === 'book-import' || view === 'projects') { return view; } return 'projects'; @@ -58,6 +59,7 @@ export default function ProjectList() { const [drawerVisible, setDrawerVisible] = useState(false); const [collapsed, setCollapsed] = useState<boolean>(() => getStoredSidebarCollapsed()); const [modal, contextHolder] = Modal.useModal(); + const [currentUser, setCurrentUser] = useState<User | null>(null); const [showApiTip, setShowApiTip] = useState(true); const [importModalVisible, setImportModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false); @@ -113,6 +115,7 @@ export default function ProjectList() { useEffect(() => { refreshProjects(); + authApi.getCurrentUser().then(setCurrentUser).catch(() => setCurrentUser(null)); // 监听切换到 MCP 视图的事件 eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp); @@ -396,7 +399,11 @@ export default function ProjectList() { ? '拆书导入' : activeView === 'mcp' ? 'MCP 插件' - : 'API 设置'; + : activeView === 'system-settings' + ? '系统设置' + : 'API 设置'; + + const isAdmin = !!currentUser?.is_admin; const sideMenuItems = [ { @@ -434,6 +441,11 @@ export default function ProjectList() { icon: <SettingOutlined />, label: 'API 设置', }, + ...(isAdmin ? [{ + key: 'system-settings', + icon: <MailOutlined />, + label: '系统设置', + }] : []), { key: 'mumu-api', icon: <ApiOutlined />, @@ -469,6 +481,11 @@ export default function ProjectList() { icon: <SettingOutlined />, label: 'API 设置', }, + ...(isAdmin ? [{ + key: 'system-settings', + icon: <MailOutlined />, + label: '系统设置', + }] : []), { key: 'mumu-api', icon: <ApiOutlined />, @@ -839,6 +856,7 @@ export default function ProjectList() { }} > {activeView === 'settings' && <SettingsPage />} + {activeView === 'system-settings' && <SystemSettingsPage />} {activeView === 'mcp' && <MCPPluginsPage />} {activeView === 'prompts' && <PromptTemplates />} diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx new file mode 100644 index 0000000..a2d2886 --- /dev/null +++ b/frontend/src/pages/SystemSettings.tsx @@ -0,0 +1,289 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Card, Col, Form, Input, InputNumber, Row, Select, Space, Spin, Switch, Tabs, Typography, message, theme } from 'antd'; +import { CheckCircleOutlined, MailOutlined, ReloadOutlined, SaveOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; +import { authApi, settingsApi } from '../services/api'; +import type { SystemSMTPSettings, SystemSMTPSettingsUpdate, User } from '../types'; + +const { Title, Text, Paragraph } = Typography; +const { Option } = Select; + +const qqDefaults: Pick<SystemSMTPSettings, 'smtp_provider' | 'smtp_host' | 'smtp_port' | 'smtp_use_ssl' | 'smtp_use_tls'> = { + smtp_provider: 'qq', + smtp_host: 'smtp.qq.com', + smtp_port: 465, + smtp_use_ssl: true, + smtp_use_tls: false, +}; + +export default function SystemSettingsPage() { + const { token } = theme.useToken(); + const [form] = Form.useForm<SystemSMTPSettingsUpdate>(); + const [currentUser, setCurrentUser] = useState<User | null>(null); + const [initialLoading, setInitialLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + const [testTargetEmail, setTestTargetEmail] = useState(''); + + const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`; + const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`; + const footerSafeOffset = 88; + + const loadData = async () => { + setInitialLoading(true); + try { + const [user, smtpSettings] = await Promise.all([ + authApi.getCurrentUser(), + settingsApi.getSystemSMTPSettings(), + ]); + setCurrentUser(user); + form.setFieldsValue(smtpSettings); + } catch (error) { + console.error('加载系统设置失败:', error); + message.error('加载系统设置失败'); + } finally { + setInitialLoading(false); + } + }; + + useEffect(() => { + loadData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleProviderChange = (value: string) => { + if (value === 'qq') { + form.setFieldsValue(qqDefaults); + } + }; + + const handleSave = async (values: SystemSMTPSettingsUpdate) => { + setSaving(true); + try { + const payload = values.smtp_provider === 'qq' + ? { + ...values, + ...qqDefaults, + smtp_username: values.smtp_username, + smtp_password: values.smtp_password, + smtp_from_email: values.smtp_from_email, + smtp_from_name: values.smtp_from_name, + email_auth_enabled: values.email_auth_enabled, + email_register_enabled: values.email_register_enabled, + verification_code_ttl_minutes: values.verification_code_ttl_minutes, + verification_resend_interval_seconds: values.verification_resend_interval_seconds, + } + : values; + const result = await settingsApi.updateSystemSMTPSettings(payload); + form.setFieldsValue(result); + message.success('系统 SMTP 设置已保存'); + } catch (error) { + console.error('保存系统设置失败:', error); + message.error('保存系统设置失败'); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + const toEmail = testTargetEmail.trim(); + if (!toEmail) { + message.warning('请先填写测试目标邮箱'); + return; + } + + setTesting(true); + try { + const result = await settingsApi.testSystemSMTPSettings({ to_email: toEmail }); + if (result.success) { + message.success(result.message); + } else { + message.error(result.message || 'SMTP 测试失败'); + } + } catch (error) { + console.error('测试 SMTP 配置失败:', error); + message.error('测试 SMTP 配置失败'); + } finally { + setTesting(false); + } + }; + + if (initialLoading) { + return ( + <div style={{ minHeight: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgLayout }}> + <Spin size="large" /> + </div> + ); + } + + if (!currentUser?.is_admin) { + return ( + <div style={{ padding: 24 }}> + <Alert type="error" showIcon message="无权限访问" description="只有管理员可以访问系统设置。" /> + </div> + ); + } + + return ( + <div + style={{ + minHeight: `calc(100vh - ${footerSafeOffset}px)`, + boxSizing: 'border-box', + background: pageBackground, + padding: 24, + paddingBottom: footerSafeOffset, + }} + > + <Card + bordered={false} + style={{ + marginBottom: 24, + borderRadius: 20, + overflow: 'hidden', + boxShadow: `0 12px 32px ${token.colorFillSecondary}`, + }} + bodyStyle={{ padding: 0 }} + > + <div style={{ background: headerBackground, padding: '28px 32px', color: '#fff' }}> + <Space direction="vertical" size={6}> + <Space> + <SettingOutlined /> + <Title level={3} style={{ color: '#fff', margin: 0 }}>系统设置 + + + 仅管理员可见,用于维护 SMTP 发信能力与邮箱注册相关系统参数。 + + +
+ + + + + SMTP 配置 + + ), + children: ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setTestTargetEmail(e.target.value)} + placeholder="请输入测试目标邮箱,如 123456@qq.com" + /> + + + + } + message="建议使用 QQ 默认配置" + description={先保存 SMTP 配置,再填写测试目标邮箱,点击“发送测试邮件”后由后端通过 SMTP 实际发信。} + /> + + + + +
+ ), + }, + ]} + /> +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0aab557..ac1bb5c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -98,14 +98,25 @@ api.interceptors.response.use( case 400: errorMessage = data?.detail || '请求参数错误'; break; - case 401: - errorMessage = '未授权,请先登录'; - if (window.location.pathname !== '/login') { + case 401: { + const backendDetail = data?.detail || data?.message; + const unauthenticatedDetails = [ + '未登录', + '需要登录', + '未登录或用户ID缺失', + '未登录,无法刷新会话', + ]; + const isUnauthenticated = unauthenticatedDetails.includes(backendDetail); + + errorMessage = backendDetail || '登录状态已失效,请重新登录'; + + if (isUnauthenticated && window.location.pathname !== '/login') { window.location.href = '/login'; } break; + } case 403: - errorMessage = '没有权限访问'; + errorMessage = data?.detail || '没有权限访问'; break; case 404: errorMessage = data?.detail || '请求的资源不存在'; @@ -139,7 +150,12 @@ api.interceptors.response.use( ); export const authApi = { - getAuthConfig: () => api.get('/auth/config'), + getAuthConfig: () => api.get('/auth/config'), localLogin: (username: string, password: string) => api.post('/auth/local/login', { username, password }), @@ -147,6 +163,18 @@ export const authApi = { bindAccountLogin: (username: string, password: string) => api.post('/auth/bind/login', { username, password }), + emailLogin: (payload: import('../types').EmailLoginPayload) => + api.post('/auth/email/login', payload), + + sendEmailCode: (payload: import('../types').EmailSendCodePayload) => + api.post('/auth/email/send-code', payload), + + emailRegister: (payload: import('../types').EmailRegisterPayload) => + api.post('/auth/email/register', payload), + + resetEmailPassword: (payload: import('../types').EmailResetPasswordPayload) => + api.post('/auth/email/reset-password', payload), + getLinuxDOAuthUrl: () => api.get('/auth/linuxdo/url'), getCurrentUser: () => api.get('/auth/user'), @@ -290,6 +318,15 @@ export const settingsApi = { api.post('/settings/presets/from-current', null, { params: { name, description } }), + + getSystemSMTPSettings: () => + api.get('/settings/system/smtp'), + + updateSystemSMTPSettings: (data: import('../types').SystemSMTPSettingsUpdate) => + api.put('/settings/system/smtp', data), + + testSystemSMTPSettings: (data: { to_email: string }) => + api.post('/settings/system/smtp/test', data), }; export const projectApi = { diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3349a4c..8027364 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -11,6 +11,65 @@ export interface User { last_login: string; } +export interface EmailLoginPayload { + email: string; + code: string; +} + +export interface EmailRegisterPayload { + email: string; + code: string; + password: string; + display_name?: string; +} + +export interface EmailSendCodePayload { + email: string; + scene: 'register' | 'login' | 'reset_password'; +} + +export interface EmailResetPasswordPayload { + email: string; + code: string; + new_password: string; +} + +export interface SystemSMTPSettings { + id: string; + user_id: string; + smtp_provider: string; + smtp_host?: string; + smtp_port: number; + smtp_username?: string; + smtp_password?: string; + smtp_use_tls: boolean; + smtp_use_ssl: boolean; + smtp_from_email?: string; + smtp_from_name: string; + email_auth_enabled: boolean; + email_register_enabled: boolean; + verification_code_ttl_minutes: number; + verification_resend_interval_seconds: number; + created_at: string; + updated_at: string; +} + +export interface SystemSMTPSettingsUpdate { + smtp_provider?: string; + smtp_host?: string; + smtp_port?: number; + smtp_username?: string; + smtp_password?: string; + smtp_use_tls?: boolean; + smtp_use_ssl?: boolean; + smtp_from_email?: string; + smtp_from_name?: string; + email_auth_enabled?: boolean; + email_register_enabled?: boolean; + verification_code_ttl_minutes?: number; + verification_resend_interval_seconds?: number; +} + // 设置类型定义 export interface Settings { id: string;