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 测试邮件。
+
+ - 发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
+ - SMTP 服务商:{settings.smtp_provider}
+ - SMTP 主机:{settings.smtp_host}:{settings.smtp_port}
+
+
如果你收到这封邮件,说明当前 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 = () => (
- }
+ )}
loading={loading}
onClick={handleLinuxDOLogin}
block
@@ -209,51 +761,51 @@ export default function Login() {
);
- 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() {
- {localAuthEnabled ? renderLocalLogin() : null}
-
- {linuxdoEnabled && localAuthEnabled ? (
- <>
-
或
- {renderLinuxDOLogin()}
- >
+ {authTabs.length > 0 ? (
+
) : null}
- {!localAuthEnabled && linuxdoEnabled ? renderLinuxDOLogin() : null}
-
- {!localAuthEnabled && !linuxdoEnabled ? (
+ {!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? (
+ ) : null}
+
+ {emailAuthEnabled && !emailRegisterEnabled ? (
+
) : null}
@@ -500,4 +1054,4 @@ export default function Login() {
>
);
-}
\ 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
(() => getStoredSidebarCollapsed());
const [modal, contextHolder] = Modal.useModal();
+ const [currentUser, setCurrentUser] = useState(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: ,
label: 'API 设置',
},
+ ...(isAdmin ? [{
+ key: 'system-settings',
+ icon: ,
+ label: '系统设置',
+ }] : []),
{
key: 'mumu-api',
icon: ,
@@ -469,6 +481,11 @@ export default function ProjectList() {
icon: ,
label: 'API 设置',
},
+ ...(isAdmin ? [{
+ key: 'system-settings',
+ icon: ,
+ label: '系统设置',
+ }] : []),
{
key: 'mumu-api',
icon: ,
@@ -839,6 +856,7 @@ export default function ProjectList() {
}}
>
{activeView === 'settings' && }
+ {activeView === 'system-settings' && }
{activeView === 'mcp' && }
{activeView === 'prompts' && }
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 = {
+ 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();
+ const [currentUser, setCurrentUser] = useState(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 (
+
+
+
+ );
+ }
+
+ if (!currentUser?.is_admin) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ 系统设置
+
+
+ 仅管理员可见,用于维护 SMTP 发信能力与邮箱注册相关系统参数。
+
+
+
+
+
+
+
+ SMTP 配置
+
+ ),
+ children: (
+
+ ),
+ },
+ ]}
+ />
+
+ );
+}
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;