feature:新增邮箱注册登录功能

This commit is contained in:
xiamuceer
2026-03-20 11:06:25 +08:00
parent 7df6f52a9e
commit d72dd1c555
16 changed files with 2074 additions and 349 deletions
+8 -7
View File
@@ -8,6 +8,7 @@ on:
env: env:
DOCKER_IMAGE: mumujie/mumuainovel DOCKER_IMAGE: mumujie/mumuainovel
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
build-and-push: build-and-push:
@@ -15,29 +16,29 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v4
with: with:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v4
with: with:
driver-opts: | driver-opts: |
image=moby/buildkit:master image=moby/buildkit:master
network=host network=host
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) - name: Extract metadata (tags, labels)
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v6
with: with:
images: ${{ env.DOCKER_IMAGE }} images: ${{ env.DOCKER_IMAGE }}
tags: | tags: |
@@ -53,7 +54,7 @@ jobs:
type=sha,prefix=sha- type=sha,prefix=sha-
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v7
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -68,7 +69,7 @@ jobs:
- name: Update Docker Hub Description - name: Update Docker Hub Description
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
uses: peter-evans/dockerhub-description@v4 uses: peter-evans/dockerhub-description@v5
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
+17
View File
@@ -93,6 +93,23 @@ LOCAL_AUTH_DISPLAY_NAME=本地管理员
SESSION_EXPIRE_MINUTES=120 SESSION_EXPIRE_MINUTES=120
SESSION_REFRESH_THRESHOLD_MINUTES=30 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
# ========================================== # ==========================================
# 提示词工坊配置 # 提示词工坊配置
# ========================================== # ==========================================
@@ -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 ###
@@ -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 ###
+470 -148
View File
@@ -1,25 +1,36 @@
""" """
认证 API - LinuxDO OAuth2 登录 + 本地账户登录 认证 API - LinuxDO OAuth2 登录 + 本地账户登录 + 邮箱验证码注册/登录
""" """
from fastapi import APIRouter, HTTPException, Response, Request from fastapi import APIRouter, HTTPException, Response, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import hashlib import hashlib
import random
import re
from datetime import datetime, timedelta, timezone 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.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.user_password import password_manager
from app.logger import get_logger from app.logger import get_logger
from app.config import settings 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 # 中国时区 UTC+8
CHINA_TZ = timezone(timedelta(hours=8)) CHINA_TZ = timezone(timedelta(hours=8))
def get_china_now(): def get_china_now():
"""获取中国当前时间""" """获取中国当前时间"""
return datetime.now(CHINA_TZ) return datetime.now(CHINA_TZ)
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(prefix="/auth", tags=["认证"]) router = APIRouter(prefix="/auth", tags=["认证"])
@@ -30,6 +41,11 @@ oauth_service = LinuxDOOAuthService()
# State 临时存储(生产环境应使用 Redis) # State 临时存储(生产环境应使用 Redis)
_state_storage = {} _state_storage = {}
# 邮箱验证码临时存储(生产环境应使用 Redis)
_email_verification_storage = {}
EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
class AuthUrlResponse(BaseModel): class AuthUrlResponse(BaseModel):
auth_url: str auth_url: str
@@ -42,8 +58,35 @@ class LocalLoginRequest(BaseModel):
password: str 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): class LocalLoginResponse(BaseModel):
"""本地登录响应""" """登录响应"""
success: bool success: bool
message: str message: str
user: Optional[dict] = None user: Optional[dict] = None
@@ -68,44 +111,249 @@ class PasswordStatusResponse(BaseModel):
default_password: Optional[str] = None 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"""
<div style="font-family: Arial, PingFang SC, Microsoft YaHei, sans-serif; line-height: 1.8; color: #1f2937;">
<h2 style="margin-bottom: 16px;">MuMuAINovel {scene_title}</h2>
<p>{scene_desc}</p>
<p>你的验证码为:</p>
<div style="display: inline-block; padding: 10px 18px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; font-size: 28px; font-weight: 700; letter-spacing: 4px; color: #2563eb;">
{code}
</div>
<p style="margin-top: 16px;">有效期:{ttl_minutes} 分钟</p>
<p>如果这不是你的操作,请忽略本邮件。</p>
</div>
"""
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") @router.get("/config")
async def get_auth_config(): async def get_auth_config():
"""获取认证配置信息""" """获取认证配置信息"""
runtime = await _get_auth_runtime_settings()
return { return {
"local_auth_enabled": settings.LOCAL_AUTH_ENABLED, "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) @router.post("/local/login", response_model=LocalLoginResponse)
async def local_login(request: LocalLoginRequest, response: Response): async def local_login(request: LocalLoginRequest, response: Response):
"""本地账户登录(支持.env配置的管理员账号和Linux DO授权后绑定的账号)""" """本地账户登录(支持.env配置的管理员账号和Linux DO授权后绑定的账号)"""
# 检查是否启用本地登录
if not settings.LOCAL_AUTH_ENABLED: if not settings.LOCAL_AUTH_ENABLED:
raise HTTPException(status_code=403, detail="本地账户登录未启用") raise HTTPException(status_code=403, detail="本地账户登录未启用")
logger.info(f"[本地登录] 尝试登录用户名: {request.username}") logger.info(f"[本地登录] 尝试登录用户名: {request.username}")
# 首先尝试查找 Linux DO 授权后绑定的账号
all_users = await user_manager.get_all_users() all_users = await user_manager.get_all_users()
target_user = None target_user = None
for user in all_users: for user in all_users:
# 同时检查 users 表的 username 和 user_passwords 表的 username
password_username = await password_manager.get_username(user.user_id) password_username = await password_manager.get_username(user.user_id)
if user.username == request.username or password_username == request.username: if user.username == request.username or password_username == request.username:
target_user = user target_user = user
logger.info(f"[本地登录] 找到 Linux DO 授权用户: {user.user_id}") logger.info(f"[本地登录] 找到 Linux DO 授权用户: {user.user_id}")
break break
# 如果找到了 Linux DO 授权的用户
if target_user: if target_user:
# 检查是否有密码
if not await password_manager.has_password(target_user.user_id): if not await password_manager.has_password(target_user.user_id):
logger.warning(f"[本地登录] 用户 {target_user.user_id} 没有设置密码") logger.warning(f"[本地登录] 用户 {target_user.user_id} 没有设置密码")
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# 验证密码
if not await password_manager.verify_password(target_user.user_id, request.password): if not await password_manager.verify_password(target_user.user_id, request.password):
logger.warning(f"[本地登录] 用户 {target_user.user_id} 密码验证失败") logger.warning(f"[本地登录] 用户 {target_user.user_id} 密码验证失败")
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
@@ -113,71 +361,37 @@ async def local_login(request: LocalLoginRequest, response: Response):
logger.info(f"[本地登录] Linux DO 授权用户 {target_user.user_id} 登录成功") logger.info(f"[本地登录] Linux DO 授权用户 {target_user.user_id} 登录成功")
user = target_user user = target_user
else: else:
# 没有找到 Linux DO 用户,尝试 .env 配置的管理员账号
logger.info(f"[本地登录] 未找到 Linux DO 用户,检查 .env 管理员账号") logger.info(f"[本地登录] 未找到 Linux DO 用户,检查 .env 管理员账号")
# 检查是否配置了本地账户
if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD: if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# 生成本地用户ID(使用用户名的hash)
user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}" user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}"
# 检查用户是否存在
user = await user_manager.get_user(user_id) user = await user_manager.get_user(user_id)
# 如果用户不存在,使用.env中的默认密码验证
if not user: if not user:
# 验证用户名和密码(使用.env配置)
if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD: if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# 创建本地用户
user = await user_manager.create_or_update_from_linuxdo( user = await user_manager.create_or_update_from_linuxdo(
linuxdo_id=user_id, linuxdo_id=user_id,
username=request.username, username=request.username,
display_name=settings.LOCAL_AUTH_DISPLAY_NAME, display_name=settings.LOCAL_AUTH_DISPLAY_NAME,
avatar_url=None, avatar_url=None,
trust_level=9 # 本地用户给予高信任级别 trust_level=9
) )
# 为新用户设置默认密码到数据库
await password_manager.set_password(user.user_id, request.username, request.password) await password_manager.set_password(user.user_id, request.username, request.password)
logger.info(f"[本地登录] 管理员用户 {user.user_id} 初始密码已设置到数据库") logger.info(f"[本地登录] 管理员用户 {user.user_id} 初始密码已设置到数据库")
else: else:
# 用户已存在,使用数据库中的密码验证
if not await password_manager.verify_password(user.user_id, request.password): if not await password_manager.verify_password(user.user_id, request.password):
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功") logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功")
# Settings 将在首次访问设置页面时自动创建(延迟初始化) _set_login_cookies(response, user.user_id)
# 设置 Cookie2小时有效)
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())
logger.info(f"✅ [登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") 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( return LocalLoginResponse(
success=True, success=True,
message="登录成功", message="登录成功",
@@ -185,13 +399,213 @@ 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) @router.get("/linuxdo/url", response_model=AuthUrlResponse)
async def get_linuxdo_auth_url(): async def get_linuxdo_auth_url():
"""获取 LinuxDO 授权 URL""" """获取 LinuxDO 授权 URL"""
state = oauth_service.generate_state() state = oauth_service.generate_state()
auth_url = oauth_service.get_authorization_url(state) auth_url = oauth_service.get_authorization_url(state)
# 临时存储 state5分钟有效)
_state_storage[state] = True _state_storage[state] = True
return AuthUrlResponse(auth_url=auth_url, state=state) return AuthUrlResponse(auth_url=auth_url, state=state)
@@ -208,34 +622,27 @@ async def _handle_callback(
成功后重定向到前端首页,并设置 user_id Cookie 成功后重定向到前端首页,并设置 user_id Cookie
""" """
# 检查是否有错误
if error: if error:
raise HTTPException(status_code=400, detail=f"授权失败: {error}") raise HTTPException(status_code=400, detail=f"授权失败: {error}")
# 检查必需参数
if not code or not state: if not code or not state:
raise HTTPException(status_code=400, detail="缺少 code 或 state 参数") raise HTTPException(status_code=400, detail="缺少 code 或 state 参数")
# 验证 state(防止 CSRF
if state not in _state_storage: if state not in _state_storage:
raise HTTPException(status_code=400, detail="无效的 state 参数") raise HTTPException(status_code=400, detail="无效的 state 参数")
# 删除已使用的 state
del _state_storage[state] del _state_storage[state]
# 1. 使用 code 获取 access_token
token_data = await oauth_service.get_access_token(code) token_data = await oauth_service.get_access_token(code)
if not token_data or "access_token" not in token_data: if not token_data or "access_token" not in token_data:
raise HTTPException(status_code=400, detail="获取访问令牌失败") raise HTTPException(status_code=400, detail="获取访问令牌失败")
access_token = token_data["access_token"] access_token = token_data["access_token"]
# 2. 使用 access_token 获取用户信息
user_info = await oauth_service.get_user_info(access_token) user_info = await oauth_service.get_user_info(access_token)
if not user_info: if not user_info:
raise HTTPException(status_code=400, detail="获取用户信息失败") raise HTTPException(status_code=400, detail="获取用户信息失败")
# 3. 创建或更新用户
linuxdo_id = str(user_info.get("id")) linuxdo_id = str(user_info.get("id"))
username = user_info.get("username", "") username = user_info.get("username", "")
display_name = user_info.get("name", username) display_name = user_info.get("name", username)
@@ -250,52 +657,24 @@ async def _handle_callback(
trust_level=trust_level trust_level=trust_level
) )
# 3.1. 检查是否是首次登录(没有密码记录)
is_first_login = not await password_manager.has_password(user.user_id) is_first_login = not await password_manager.has_password(user.user_id)
if is_first_login: if is_first_login:
logger.info(f"用户 {user.user_id} ({username}) 首次登录,需要初始化密码") logger.info(f"用户 {user.user_id} ({username}) 首次登录,需要初始化密码")
# Settings 将在首次访问设置页面时自动创建(延迟初始化)
# 4. 设置 Cookie 并重定向到前端回调页面
# 使用配置的前端URL,支持不同的部署环境
frontend_url = settings.FRONTEND_URL.rstrip('/') frontend_url = settings.FRONTEND_URL.rstrip('/')
redirect_url = f"{frontend_url}/auth/callback" redirect_url = f"{frontend_url}/auth/callback"
logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}") logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}")
redirect_response = RedirectResponse(url=redirect_url) redirect_response = RedirectResponse(url=redirect_url)
# 设置 httponly Cookie2小时有效) _set_login_cookies(redirect_response, user.user_id)
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())
logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") 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: if is_first_login:
redirect_response.set_cookie( redirect_response.set_cookie(
key="first_login", key="first_login",
value="true", value="true",
max_age=300, # 5分钟有效 max_age=300,
httponly=False, # 前端需要读取 httponly=False,
samesite="lax" samesite="lax"
) )
logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 首次登录,已设置 first_login 标记") logger.info(f"✅ [OAuth登录] 用户 {user.user_id} 首次登录,已设置 first_login 标记")
@@ -328,13 +707,11 @@ async def callback_alias(
@router.post("/refresh") @router.post("/refresh")
async def refresh_session(request: Request, response: Response): async def refresh_session(request: Request, response: Response):
"""刷新会话 - 延长登录状态""" """刷新会话 - 延长登录状态"""
# 检查是否已登录
if not hasattr(request.state, "user") or not request.state.user: if not hasattr(request.state, "user") or not request.state.user:
raise HTTPException(status_code=401, detail="未登录,无法刷新会话") raise HTTPException(status_code=401, detail="未登录,无法刷新会话")
user = request.state.user user = request.state.user
# 检查当前会话是否即将过期(剩余时间少于阈值)
session_expire_at = request.cookies.get("session_expire_at") session_expire_at = request.cookies.get("session_expire_at")
if session_expire_at: if session_expire_at:
try: try:
@@ -342,7 +719,6 @@ async def refresh_session(request: Request, response: Response):
current_timestamp = int(get_china_now().timestamp()) current_timestamp = int(get_china_now().timestamp())
remaining_minutes = (expire_timestamp - current_timestamp) / 60 remaining_minutes = (expire_timestamp - current_timestamp) / 60
# 如果剩余时间大于刷新阈值,不需要刷新
if remaining_minutes > settings.SESSION_REFRESH_THRESHOLD_MINUTES: if remaining_minutes > settings.SESSION_REFRESH_THRESHOLD_MINUTES:
logger.info(f"⏱️ [刷新会话] 用户 {user.user_id} 会话仍有效,剩余 {int(remaining_minutes)} 分钟") logger.info(f"⏱️ [刷新会话] 用户 {user.user_id} 会话仍有效,剩余 {int(remaining_minutes)} 分钟")
return { return {
@@ -351,19 +727,10 @@ async def refresh_session(request: Request, response: Response):
"expire_at": expire_timestamp "expire_at": expire_timestamp
} }
except (ValueError, TypeError): except (ValueError, TypeError):
pass # Cookie 格式错误,继续刷新 pass
# 刷新 Cookie _set_login_cookies(response, user.user_id)
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"
)
# 更新过期时间戳
china_now = get_china_now() china_now = get_china_now()
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES) expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
expire_at = int(expire_time.timestamp()) expire_at = int(expire_time.timestamp())
@@ -372,15 +739,7 @@ async def refresh_session(request: Request, response: Response):
logger.info(f"[刷新会话] 中国当前时间: {china_now.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)") 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_time.strftime('%Y-%m-%d %H:%M:%S')} (UTC+8)")
logger.info(f"[刷新会话] 过期时间戳 (秒): {expire_at}") logger.info(f"[刷新会话] 过期时间戳 (秒): {expire_at}")
logger.info(f"[刷新会话] Cookie max_age (秒): {max_age}") logger.info(f"[刷新会话] Cookie max_age (秒): {settings.SESSION_EXPIRE_MINUTES * 60}")
response.set_cookie(
key="session_expire_at",
value=str(expire_at),
max_age=max_age,
httponly=False,
samesite="lax"
)
logger.info(f"用户 {user.user_id} 刷新会话成功") logger.info(f"用户 {user.user_id} 刷新会话成功")
return { return {
@@ -422,7 +781,6 @@ async def get_password_status(request: Request):
has_custom = await password_manager.has_custom_password(user.user_id) has_custom = await password_manager.has_custom_password(user.user_id)
username = await password_manager.get_username(user.user_id) username = await password_manager.get_username(user.user_id)
# 如果使用默认密码,返回默认密码供用户查看
default_password = None default_password = None
if has_password and not has_custom: if has_password and not has_custom:
default_password = f"{user.username}@666" default_password = f"{user.username}@666"
@@ -442,12 +800,8 @@ async def set_user_password(request: Request, password_req: SetPasswordRequest):
raise HTTPException(status_code=401, detail="未登录") raise HTTPException(status_code=401, detail="未登录")
user = request.state.user user = request.state.user
_validate_password(password_req.password)
# 验证密码强度(至少6个字符)
if len(password_req.password) < 6:
raise HTTPException(status_code=400, detail="密码长度至少为6个字符")
# 设置密码
await password_manager.set_password(user.user_id, user.username, password_req.password) await password_manager.set_password(user.user_id, user.username, password_req.password)
logger.info(f"用户 {user.user_id} ({user.username}) 设置了自定义密码") logger.info(f"用户 {user.user_id} ({user.username}) 设置了自定义密码")
@@ -469,15 +823,11 @@ async def initialize_user_password(request: Request, password_req: SetPasswordRe
user = request.state.user user = request.state.user
# 检查是否已经有密码(防止重复初始化)
if await password_manager.has_password(user.user_id): if await password_manager.has_password(user.user_id):
raise HTTPException(status_code=400, detail="密码已经初始化,请使用密码修改功能") raise HTTPException(status_code=400, detail="密码已经初始化,请使用密码修改功能")
# 验证密码强度(至少6个字符) _validate_password(password_req.password)
if len(password_req.password) < 6:
raise HTTPException(status_code=400, detail="密码长度至少为6个字符")
# 设置密码
await password_manager.set_password(user.user_id, user.username, password_req.password) await password_manager.set_password(user.user_id, user.username, password_req.password)
logger.info(f"用户 {user.user_id} ({user.username}) 初始化密码成功") logger.info(f"用户 {user.user_id} ({user.username}) 初始化密码成功")
@@ -490,7 +840,6 @@ async def initialize_user_password(request: Request, password_req: SetPasswordRe
@router.post("/bind/login", response_model=LocalLoginResponse) @router.post("/bind/login", response_model=LocalLoginResponse)
async def bind_account_login(request: LocalLoginRequest, response: Response): async def bind_account_login(request: LocalLoginRequest, response: Response):
"""使用绑定的账号密码登录(LinuxDO授权后绑定的账号)""" """使用绑定的账号密码登录(LinuxDO授权后绑定的账号)"""
# 查找用户
all_users = await user_manager.get_all_users() all_users = await user_manager.get_all_users()
target_user = None target_user = None
@@ -498,7 +847,6 @@ async def bind_account_login(request: LocalLoginRequest, response: Response):
logger.info(f"[绑定账号登录] 当前共有 {len(all_users)} 个用户") logger.info(f"[绑定账号登录] 当前共有 {len(all_users)} 个用户")
for user in all_users: for user in all_users:
# 同时检查 users 表的 username 和 user_passwords 表的 username
password_username = await password_manager.get_username(user.user_id) password_username = await password_manager.get_username(user.user_id)
logger.info(f"[绑定账号登录] 检查用户 {user.user_id}: users.username={user.username}, passwords.username={password_username}") logger.info(f"[绑定账号登录] 检查用户 {user.user_id}: users.username={user.username}, passwords.username={password_username}")
@@ -511,46 +859,20 @@ async def bind_account_login(request: LocalLoginRequest, response: Response):
logger.warning(f"[绑定账号登录] 用户名 {request.username} 未找到") logger.warning(f"[绑定账号登录] 用户名 {request.username} 未找到")
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# 检查是否有密码记录
has_pwd = await password_manager.has_password(target_user.user_id) has_pwd = await password_manager.has_password(target_user.user_id)
if not has_pwd: if not has_pwd:
logger.warning(f"[绑定账号登录] 用户 {target_user.user_id} 没有设置密码") logger.warning(f"[绑定账号登录] 用户 {target_user.user_id} 没有设置密码")
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# 验证密码
is_valid = await password_manager.verify_password(target_user.user_id, request.password) is_valid = await password_manager.verify_password(target_user.user_id, request.password)
logger.info(f"[绑定账号登录] 用户 {target_user.user_id} 密码验证结果: {is_valid}") logger.info(f"[绑定账号登录] 用户 {target_user.user_id} 密码验证结果: {is_valid}")
if not is_valid: if not is_valid:
raise HTTPException(status_code=401, detail="用户名或密码错误") raise HTTPException(status_code=401, detail="用户名或密码错误")
# Settings 将在首次访问设置页面时自动创建(延迟初始化) _set_login_cookies(response, target_user.user_id)
# 设置 Cookie2小时有效)
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())
logger.info(f"✅ [绑定账号登录] 用户 {target_user.user_id} ({request.username}) 登录成功,会话有效期 {settings.SESSION_EXPIRE_MINUTES} 分钟") 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( return LocalLoginResponse(
success=True, success=True,
message="登录成功", message="登录成功",
+152 -1
View File
@@ -18,12 +18,14 @@ from app.services.cover_generation_service import cover_generation_service
from app.schemas.settings import ( from app.schemas.settings import (
SettingsCreate, SettingsUpdate, SettingsResponse, SettingsCreate, SettingsUpdate, SettingsResponse,
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest, APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
PresetUpdateRequest, PresetResponse, PresetListResponse PresetUpdateRequest, PresetResponse, PresetListResponse,
SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest
) )
from app.user_manager import User from app.user_manager import User
from app.logger import get_logger from app.logger import get_logger
from app.config import settings as app_settings, PROJECT_ROOT 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.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__) logger = get_logger(__name__)
@@ -56,6 +58,46 @@ def require_login(request: Request):
return request.state.user 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( async def get_user_ai_service(
user: User = Depends(require_login), user: User = Depends(require_login),
db: AsyncSession = Depends(get_db) 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"""
<div style=\"font-family: Arial, sans-serif; line-height: 1.7; color: #1f1f1f;\">
<h2 style=\"margin-bottom: 12px;\">MuMuAINovel SMTP 测试邮件</h2>
<p>这是一封来自系统设置页面的 SMTP 测试邮件。</p>
<ul>
<li><strong>发送时间:</strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</li>
<li><strong>SMTP 服务商:</strong>{settings.smtp_provider}</li>
<li><strong>SMTP 主机:</strong>{settings.smtp_host}:{settings.smtp_port}</li>
</ul>
<p>如果你收到这封邮件,说明当前 SMTP 配置可正常发送邮件。</p>
</div>
"""
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) @router.post("", response_model=SettingsResponse)
async def save_settings( async def save_settings(
data: SettingsCreate, data: SettingsCreate,
+15
View File
@@ -107,6 +107,21 @@ class Settings(BaseSettings):
SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时 SESSION_EXPIRE_MINUTES: int = 120 # 会话过期时间(分钟),默认2小时
SESSION_REFRESH_THRESHOLD_MINUTES: int = 30 # 会话刷新阈值(分钟),剩余时间少于此值时可刷新 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: 云端中央服务器 WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址 WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址
+15
View File
@@ -26,6 +26,21 @@ class Settings(Base):
cover_image_model = Column(String(100), comment="封面图片模型名称") cover_image_model = Column(String(100), comment="封面图片模型名称")
cover_enabled = Column(Boolean, default=False, server_default="0", nullable=False, 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)") preferences = Column(Text, comment="其他偏好设置(JSON)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间") created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
+41
View File
@@ -43,6 +43,47 @@ class SettingsResponse(SettingsBase):
updated_at: datetime 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配置预设相关模型 ========== # ========== API配置预设相关模型 ==========
class APIKeyPresetConfig(BaseModel): class APIKeyPresetConfig(BaseModel):
+71
View File
@@ -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()
+1
View File
@@ -22,6 +22,7 @@ anthropic==0.72.0
httpx==0.28.1 httpx==0.28.1
python-dotenv==1.1.0 python-dotenv==1.1.0
psutil==6.1.1 psutil==6.1.1
aiosmtplib==4.0.2
# MCP官方库(Model Context Protocol Python SDK # MCP官方库(Model Context Protocol Python SDK
mcp==1.22.0 mcp==1.22.0
+673 -119
View File
@@ -1,46 +1,151 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Col, Divider, Form, Input, Layout, Row, Space, Spin, Tag, Typography, message, theme } from 'antd'; import {
import { BookOutlined, LockOutlined, RobotOutlined, SafetyCertificateOutlined, TeamOutlined, ThunderboltOutlined, UserOutlined } from '@ant-design/icons'; 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 { authApi } from '../services/api';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import AnnouncementModal from '../components/AnnouncementModal'; import AnnouncementModal from '../components/AnnouncementModal';
import ThemeSwitch from '../components/ThemeSwitch'; 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() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true); const [checking, setChecking] = useState(true);
const [localAuthEnabled, setLocalAuthEnabled] = useState(false); const [authConfig, setAuthConfig] = useState<AuthConfig>({
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false); local_auth_enabled: false,
const [form] = Form.useForm(); linuxdo_enabled: false,
email_auth_enabled: false,
email_register_enabled: false,
});
const [localForm] = Form.useForm<LocalLoginValues>();
const [emailLoginForm] = Form.useForm<EmailLoginValues>();
const [emailRegisterForm] = Form.useForm<EmailRegisterValues>();
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const { token } = theme.useToken(); const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`; 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 primaryButtonShadow = `0 8px 20px ${alphaColor(token.colorPrimary, 0.28)}`;
const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`; const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`;
const [showAnnouncement, setShowAnnouncement] = useState(false); 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(() => { useEffect(() => {
const checkAuth = async () => { const checkAuth = async () => {
try { try {
await authApi.getCurrentUser(); await authApi.getCurrentUser();
// 已登录,重定向到首页
const redirect = searchParams.get('redirect') || '/'; const redirect = searchParams.get('redirect') || '/';
navigate(redirect); navigate(redirect);
} catch { } catch {
// 未登录,获取认证配置
try { try {
const config = await authApi.getAuthConfig(); const config = await authApi.getAuthConfig();
setLocalAuthEnabled(config.local_auth_enabled); setAuthConfig(config);
setLinuxdoEnabled(config.linuxdo_enabled);
} catch (error) { } catch (error) {
console.error('获取认证配置失败:', error); console.error('获取认证配置失败:', error);
// 默认显示LinuxDO登录 setAuthConfig({
setLinuxdoEnabled(true); local_auth_enabled: false,
linuxdo_enabled: true,
email_auth_enabled: false,
email_register_enabled: false,
});
} }
setChecking(false); setChecking(false);
} }
@@ -48,29 +153,131 @@ export default function Login() {
checkAuth(); checkAuth();
}, [navigate, searchParams]); }, [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 { try {
setLoading(true); setLoading(true);
const response = await authApi.localLogin(values.username, values.password); const response = await authApi.localLogin(values.username, values.password);
if (response.success) { if (response.success) {
message.success('登录成功!'); handleLoginSuccess();
// 检查是否永久隐藏公告
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);
}
} }
} catch (error) { } catch (error) {
console.error('本地登录失败:', 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); setLoading(false);
} }
}; };
@@ -80,13 +287,11 @@ export default function Login() {
setLoading(true); setLoading(true);
const response = await authApi.getLinuxDOAuthUrl(); const response = await authApi.getLinuxDOAuthUrl();
// 保存重定向地址到 sessionStorage
const redirect = searchParams.get('redirect'); const redirect = searchParams.get('redirect');
if (redirect) { if (redirect) {
sessionStorage.setItem('login_redirect', redirect); sessionStorage.setItem('login_redirect', redirect);
} }
// 跳转到 LinuxDO 授权页面
window.location.href = response.auth_url; window.location.href = response.auth_url;
} catch (error) { } catch (error) {
console.error('获取授权地址失败:', error); console.error('获取授权地址失败:', error);
@@ -95,53 +300,397 @@ export default function Login() {
} }
}; };
if (checking) { const handleAnnouncementClose = () => {
return ( setShowAnnouncement(false);
<div style={{ const redirect = searchParams.get('redirect') || '/';
display: 'flex', navigate(redirect);
justifyContent: 'center', };
alignItems: 'center',
minHeight: '100vh', const handleDoNotShowToday = () => {
background: token.colorBgLayout, const today = new Date().toDateString();
}}> localStorage.setItem('announcement_hide_today', today);
<Spin size="large" style={{ color: token.colorPrimary }} /> };
</div>
); 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: <RobotOutlined />,
title: '多 AI 模型协同',
description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。',
},
{
icon: <ThunderboltOutlined />,
title: '智能向导驱动',
description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。',
},
{
icon: <TeamOutlined />,
title: '角色组织管理',
description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。',
},
{
icon: <BookOutlined />,
title: '章节创作闭环',
description: '支持章节生成、编辑、重写与润色,持续提升内容质量。',
},
];
// 渲染本地登录表单
const renderLocalLogin = () => ( const renderLocalLogin = () => (
<>
<Form
form={localForm}
layout="vertical"
onFinish={handleLocalLogin}
size="large"
style={{ marginTop: 16 }}
>
<Form.Item
name="username"
label="管理账号"
rules={[{ required: true, message: '请输入管理账号/邮箱' }]}
>
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱"
autoComplete="username"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item
name="password"
label="访问密钥"
rules={[{ required: true, message: '请输入访问密钥' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
>
</Button>
</Form.Item>
</Form>
{linuxdoEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null}
</>
);
const renderEmailLogin = () => {
if (showResetPassword) {
return (
<div style={{ marginTop: 16 }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Title level={5} style={{ margin: 0 }}> / </Title>
<Button type="link" style={{ paddingInline: 0 }} onClick={() => setShowResetPassword(false)}>
</Button>
</Space>
<Card size="small" bordered={false} style={{ borderRadius: 12, background: token.colorFillAlter }}>
<Form
form={resetPasswordForm}
layout="vertical"
onFinish={handleResetPassword}
size="middle"
>
<Form.Item
name="email"
label="注册邮箱"
rules={[
{ required: true, message: '请输入注册邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input prefix={<MailOutlined />} placeholder="请输入注册邮箱" />
</Form.Item>
<Form.Item label="重置验证码" required style={{ marginBottom: 12 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入重置验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input placeholder="请输入重置验证码" maxLength={6} />
</Form.Item>
<Button
onClick={sendResetCode}
loading={resetCodeSending}
disabled={resetCountdown > 0}
>
{resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
name="new_password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
</Form.Item>
<Form.Item
name="confirmNewPassword"
label="确认新密码"
dependencies={['new_password']}
rules={[
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请再次输入新密码" />
</Form.Item>
<Button type="default" htmlType="submit" loading={loading} block>
</Button>
</Form>
</Card>
</Space>
</div>
);
}
return (
<Form
form={emailLoginForm}
layout="vertical"
onFinish={handleEmailLogin}
size="large"
style={{ marginTop: 16 }}
>
<Form.Item
name="email"
label="邮箱地址"
rules={[
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入已注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item label="登录验证码" required style={{ marginBottom: 24 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入登录验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位登录验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendLoginCode}
loading={loginCodeSending}
disabled={loginCountdown > 0}
>
{loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
>
</Button>
</Form.Item>
<div style={{ marginTop: 12, textAlign: 'right' }}>
<Button type="link" style={{ paddingInline: 0 }} onClick={() => setShowResetPassword(true)}>
</Button>
</div>
</Form>
);
};
const renderEmailRegister = () => (
<Form <Form
form={form} form={emailRegisterForm}
layout="vertical" layout="vertical"
onFinish={handleLocalLogin} onFinish={handleEmailRegister}
size="large" size="large"
style={{ marginTop: '16px' }} style={{ marginTop: 16 }}
> >
<Form.Item <Form.Item
name="username" name="email"
label="管理账号" label="注册邮箱"
rules={[{ required: true, message: '请输入管理账号' }]} rules={[
{ required: true, message: '请输入注册邮箱' },
{ type: 'email', message: '请输入有效的邮箱地址' },
]}
>
<Input
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item label="邮箱验证码" required style={{ marginBottom: 12 }}>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="code"
noStyle
rules={[
{ required: true, message: '请输入邮箱验证码' },
{ len: 6, message: '验证码长度为 6 位' },
]}
>
<Input
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendRegisterCode}
loading={registerCodeSending}
disabled={registerCountdown > 0}
>
{registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
name="display_name"
label="昵称"
rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}
> >
<Input <Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />} prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号" placeholder="选填,默认使用邮箱前缀"
autoComplete="username" autoComplete="nickname"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 12 }}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="password" name="password"
label="访问密钥" label="登录密码"
rules={[{ required: true, message: '请输入访问密钥' }]} rules={[
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
> >
<Input.Password <Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />} prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥" placeholder="请输入登录密码"
autoComplete="current-password" autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 12 }}
/> />
</Form.Item> </Form.Item>
<Form.Item
name="confirmPassword"
label="确认密码"
dependencies={['password']}
rules={[
{ required: true, message: '请再次输入登录密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请再次输入登录密码"
autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}> <Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
<Button <Button
type="primary" type="primary"
@@ -158,19 +707,22 @@ export default function Login() {
boxShadow: primaryButtonShadow, boxShadow: primaryButtonShadow,
}} }}
> >
</Button> </Button>
</Form.Item> </Form.Item>
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
</Text>
</Form> </Form>
); );
// 渲染LinuxDO登录
const renderLinuxDOLogin = () => ( const renderLinuxDOLogin = () => (
<div> <div>
<Button <Button
type="primary" type="primary"
size="large" size="large"
icon={ icon={(
<img <img
src="/favicon.ico" src="/favicon.ico"
alt="LinuxDO" alt="LinuxDO"
@@ -181,7 +733,7 @@ export default function Login() {
verticalAlign: 'middle', verticalAlign: 'middle',
}} }}
/> />
} )}
loading={loading} loading={loading}
onClick={handleLinuxDOLogin} onClick={handleLinuxDOLogin}
block block
@@ -209,51 +761,51 @@ export default function Login() {
</div> </div>
); );
const handleAnnouncementClose = () => { const authTabs = [
setShowAnnouncement(false); ...(localAuthEnabled
const redirect = searchParams.get('redirect') || '/'; ? [
navigate(redirect); {
}; key: 'local-login',
label: '本地登录',
const handleDoNotShowToday = () => { children: renderLocalLogin(),
// 设置今日不再显示 },
const today = new Date().toDateString(); ]
localStorage.setItem('announcement_hide_today', today); : []),
}; ...(emailAuthEnabled
? [
const handleNeverShow = () => { {
// 设置永久不再显示 key: 'email-login',
localStorage.setItem('announcement_hide_forever', 'true'); label: '邮箱登录',
}; children: renderEmailLogin(),
},
const loginTips = [ ]
'本地登录默认账号:admin / admin123', : []),
'首次 LinuxDO 登录会自动创建账号', ...(emailAuthEnabled && emailRegisterEnabled
'系统采用多用户数据隔离机制,每位用户拥有独立的创作空间与配置。', ? [
{
key: 'email-register',
label: '邮箱注册',
children: renderEmailRegister(),
},
]
: []),
]; ];
const featureItems = [ if (checking) {
{ return (
icon: <RobotOutlined />, <div
title: '多 AI 模型协同', style={{
description: '支持 OpenAI、Gemini、Claude 等主流模型,按场景灵活切换。', display: 'flex',
}, justifyContent: 'center',
{ alignItems: 'center',
icon: <ThunderboltOutlined />, minHeight: '100vh',
title: '智能向导驱动', background: token.colorBgLayout,
description: '自动生成大纲、角色与世界观,快速搭建完整故事骨架。', }}
}, >
{ <Spin size="large" style={{ color: token.colorPrimary }} />
icon: <TeamOutlined />, </div>
title: '角色组织管理', );
description: '人物关系、组织架构可视化管理,复杂设定也能清晰掌控。', }
},
{
icon: <BookOutlined />,
title: '章节创作闭环',
description: '支持章节生成、编辑、重写与润色,持续提升内容质量。',
},
];
return ( return (
<> <>
@@ -313,7 +865,6 @@ export default function Login() {
justifyContent: 'space-between', justifyContent: 'space-between',
gap: 34, gap: 34,
width: '100%', width: '100%',
// flex: 1,
}} }}
> >
<Space align="center" size={14}> <Space align="center" size={14}>
@@ -444,7 +995,7 @@ export default function Login() {
background: token.colorBgLayout, background: token.colorBgLayout,
}} }}
> >
<div style={{ width: '100%', maxWidth: 480 }}> <div style={{ width: '100%', maxWidth: 520 }}>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Title level={2} style={{ marginBottom: 0, fontWeight: 700, color: token.colorText }}> <Title level={2} style={{ marginBottom: 0, fontWeight: 700, color: token.colorText }}>
@@ -455,23 +1006,26 @@ export default function Login() {
</Space> </Space>
<div style={{ marginTop: 22 }}> <div style={{ marginTop: 22 }}>
{localAuthEnabled ? renderLocalLogin() : null} {authTabs.length > 0 ? (
<Tabs defaultActiveKey={authTabs[0].key} items={authTabs} />
{linuxdoEnabled && localAuthEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null} ) : null}
{!localAuthEnabled && linuxdoEnabled ? renderLinuxDOLogin() : null} {!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? (
{!localAuthEnabled && !linuxdoEnabled ? (
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
message="当前未启用可用登录方式" message="当前未启用可用登录方式"
description="请联系管理员在系统配置中启用本地登录或 LinuxDO OAuth 登录。" description="请联系管理员在系统配置中启用本地登录、邮箱认证或 LinuxDO OAuth 登录。"
/>
) : null}
{emailAuthEnabled && !emailRegisterEnabled ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12, borderRadius: 12 }}
message="邮箱注册暂未开放"
description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。"
/> />
) : null} ) : null}
+24 -6
View File
@@ -1,18 +1,19 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; 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 { 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 { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined, MailOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api'; import { authApi, projectApi } from '../services/api';
import { useStore } from '../store'; import { useStore } from '../store';
import { useProjectSync } from '../store/hooks'; import { useProjectSync } from '../store/hooks';
import { eventBus, EventNames } from '../store/eventBus'; import { eventBus, EventNames } from '../store/eventBus';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Project } from '../types'; import type { Project, User } from '../types';
import UserMenu from '../components/UserMenu'; import UserMenu from '../components/UserMenu';
import ChangelogFloatingButton from '../components/ChangelogFloatingButton'; import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import ThemeSwitch from '../components/ThemeSwitch'; import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode'; import { useThemeMode } from '../theme/useThemeMode';
import SettingsPage from './Settings'; import SettingsPage from './Settings';
import SystemSettingsPage from './SystemSettings';
import MCPPluginsPage from './MCPPlugins'; import MCPPluginsPage from './MCPPlugins';
import PromptTemplates from './PromptTemplates'; import PromptTemplates from './PromptTemplates';
import BookImport from './BookImport'; 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 parseViewFromSearch = (search: string): ProjectListView => {
const view = new URLSearchParams(search).get('view'); 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 view;
} }
return 'projects'; return 'projects';
@@ -58,6 +59,7 @@ export default function ProjectList() {
const [drawerVisible, setDrawerVisible] = useState(false); const [drawerVisible, setDrawerVisible] = useState(false);
const [collapsed, setCollapsed] = useState<boolean>(() => getStoredSidebarCollapsed()); const [collapsed, setCollapsed] = useState<boolean>(() => getStoredSidebarCollapsed());
const [modal, contextHolder] = Modal.useModal(); const [modal, contextHolder] = Modal.useModal();
const [currentUser, setCurrentUser] = useState<User | null>(null);
const [showApiTip, setShowApiTip] = useState(true); const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false); const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false);
@@ -113,6 +115,7 @@ export default function ProjectList() {
useEffect(() => { useEffect(() => {
refreshProjects(); refreshProjects();
authApi.getCurrentUser().then(setCurrentUser).catch(() => setCurrentUser(null));
// 监听切换到 MCP 视图的事件 // 监听切换到 MCP 视图的事件
eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp); eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp);
@@ -396,7 +399,11 @@ export default function ProjectList() {
? '拆书导入' ? '拆书导入'
: activeView === 'mcp' : activeView === 'mcp'
? 'MCP 插件' ? 'MCP 插件'
: 'API 设置'; : activeView === 'system-settings'
? '系统设置'
: 'API 设置';
const isAdmin = !!currentUser?.is_admin;
const sideMenuItems = [ const sideMenuItems = [
{ {
@@ -434,6 +441,11 @@ export default function ProjectList() {
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: 'API 设置', label: 'API 设置',
}, },
...(isAdmin ? [{
key: 'system-settings',
icon: <MailOutlined />,
label: '系统设置',
}] : []),
{ {
key: 'mumu-api', key: 'mumu-api',
icon: <ApiOutlined />, icon: <ApiOutlined />,
@@ -469,6 +481,11 @@ export default function ProjectList() {
icon: <SettingOutlined />, icon: <SettingOutlined />,
label: 'API 设置', label: 'API 设置',
}, },
...(isAdmin ? [{
key: 'system-settings',
icon: <MailOutlined />,
label: '系统设置',
}] : []),
{ {
key: 'mumu-api', key: 'mumu-api',
icon: <ApiOutlined />, icon: <ApiOutlined />,
@@ -839,6 +856,7 @@ export default function ProjectList() {
}} }}
> >
{activeView === 'settings' && <SettingsPage />} {activeView === 'settings' && <SettingsPage />}
{activeView === 'system-settings' && <SystemSettingsPage />}
{activeView === 'mcp' && <MCPPluginsPage />} {activeView === 'mcp' && <MCPPluginsPage />}
{activeView === 'prompts' && <PromptTemplates />} {activeView === 'prompts' && <PromptTemplates />}
+289
View File
@@ -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 }}></Title>
</Space>
<Paragraph style={{ color: 'rgba(255,255,255,0.88)', margin: 0 }}>
SMTP
</Paragraph>
</Space>
</div>
</Card>
<Tabs
defaultActiveKey="smtp"
items={[
{
key: 'smtp',
label: (
<Space>
<MailOutlined />
SMTP
</Space>
),
children: (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Row gutter={24}>
<Col xs={24} xl={16}>
<Card title="邮件服务配置" bordered={false} style={{ borderRadius: 16 }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 20 }}
message="QQ 邮箱配置说明"
description="如果选择 QQ 邮箱,请使用完整 QQ 邮箱地址作为用户名,密码处填写 SMTP 授权码,而不是 QQ 登录密码。默认推荐 smtp.qq.com + SSL 465。"
/>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="smtp_provider" label="邮件服务商" rules={[{ required: true, message: '请选择邮件服务商' }]}>
<Select onChange={handleProviderChange}>
<Option value="qq">QQ </Option>
<Option value="custom"> SMTP</Option>
</Select>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_host" label="SMTP 主机" rules={[{ required: true, message: '请输入 SMTP 主机' }]}>
<Input placeholder="例如:smtp.qq.com" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_port" label="SMTP 端口" rules={[{ required: true, message: '请输入 SMTP 端口' }]}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_username" label="SMTP 用户名" rules={[{ required: true, message: '请输入 SMTP 用户名' }]}>
<Input placeholder="完整邮箱地址" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_password" label="SMTP 密码 / 授权码" rules={[{ required: true, message: '请输入 SMTP 授权码' }]}>
<Input.Password placeholder="QQ 邮箱请填写授权码" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_from_email" label="发件人邮箱">
<Input placeholder="默认可与用户名一致" />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_from_name" label="发件人名称" rules={[{ required: true, message: '请输入发件人名称' }]}>
<Input placeholder="MuMuAINovel" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Form.Item name="smtp_use_ssl" label="启用 SSL" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_use_tls" label="启用 TLS" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
</Card>
</Col>
<Col xs={24} xl={8}>
<Card title="注册与验证码策略" bordered={false} style={{ borderRadius: 16, marginBottom: 24 }}>
<Form.Item name="email_auth_enabled" label="启用邮箱认证" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="email_register_enabled" label="启用邮箱注册" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="verification_code_ttl_minutes" label="验证码有效期(分钟)" rules={[{ required: true, message: '请输入验证码有效期' }]}>
<InputNumber style={{ width: '100%' }} min={1} max={120} />
</Form.Item>
<Form.Item name="verification_resend_interval_seconds" label="验证码重发间隔(秒)" rules={[{ required: true, message: '请输入验证码重发间隔' }]}>
<InputNumber style={{ width: '100%' }} min={10} max={3600} />
</Form.Item>
</Card>
<Card title="操作" bordered={false} style={{ borderRadius: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Input
value={testTargetEmail}
onChange={(e) => setTestTargetEmail(e.target.value)}
placeholder="请输入测试目标邮箱,如 123456@qq.com"
/>
<Button icon={<ReloadOutlined />} onClick={loadData} block>
</Button>
<Button icon={<SendOutlined />} loading={testing} onClick={handleTest} block>
</Button>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving} block onClick={() => form.submit()}>
</Button>
<Alert
type="success"
showIcon
icon={<CheckCircleOutlined />}
message="建议使用 QQ 默认配置"
description={<Text type="secondary"> SMTP SMTP </Text>}
/>
</Space>
</Card>
</Col>
</Row>
</Form>
),
},
]}
/>
</div>
);
}
+42 -5
View File
@@ -98,14 +98,25 @@ api.interceptors.response.use(
case 400: case 400:
errorMessage = data?.detail || '请求参数错误'; errorMessage = data?.detail || '请求参数错误';
break; break;
case 401: case 401: {
errorMessage = '未授权,请先登录'; const backendDetail = data?.detail || data?.message;
if (window.location.pathname !== '/login') { const unauthenticatedDetails = [
'未登录',
'需要登录',
'未登录或用户ID缺失',
'未登录,无法刷新会话',
];
const isUnauthenticated = unauthenticatedDetails.includes(backendDetail);
errorMessage = backendDetail || '登录状态已失效,请重新登录';
if (isUnauthenticated && window.location.pathname !== '/login') {
window.location.href = '/login'; window.location.href = '/login';
} }
break; break;
}
case 403: case 403:
errorMessage = '没有权限访问'; errorMessage = data?.detail || '没有权限访问';
break; break;
case 404: case 404:
errorMessage = data?.detail || '请求的资源不存在'; errorMessage = data?.detail || '请求的资源不存在';
@@ -139,7 +150,12 @@ api.interceptors.response.use(
); );
export const authApi = { export const authApi = {
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'), getAuthConfig: () => api.get<unknown, {
local_auth_enabled: boolean;
linuxdo_enabled: boolean;
email_auth_enabled: boolean;
email_register_enabled: boolean;
}>('/auth/config'),
localLogin: (username: string, password: string) => localLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }), api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
@@ -147,6 +163,18 @@ export const authApi = {
bindAccountLogin: (username: string, password: string) => bindAccountLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }), api.post<unknown, { success: boolean; message: string; user: User }>('/auth/bind/login', { username, password }),
emailLogin: (payload: import('../types').EmailLoginPayload) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/email/login', payload),
sendEmailCode: (payload: import('../types').EmailSendCodePayload) =>
api.post<unknown, { success: boolean; message: string; expire_in_seconds: number; resend_interval_seconds: number }>('/auth/email/send-code', payload),
emailRegister: (payload: import('../types').EmailRegisterPayload) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/email/register', payload),
resetEmailPassword: (payload: import('../types').EmailResetPasswordPayload) =>
api.post<unknown, { success: boolean; message: string }>('/auth/email/reset-password', payload),
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'), getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
getCurrentUser: () => api.get<unknown, User>('/auth/user'), getCurrentUser: () => api.get<unknown, User>('/auth/user'),
@@ -290,6 +318,15 @@ export const settingsApi = {
api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, { api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, {
params: { name, description } params: { name, description }
}), }),
getSystemSMTPSettings: () =>
api.get<unknown, import('../types').SystemSMTPSettings>('/settings/system/smtp'),
updateSystemSMTPSettings: (data: import('../types').SystemSMTPSettingsUpdate) =>
api.put<unknown, import('../types').SystemSMTPSettings>('/settings/system/smtp', data),
testSystemSMTPSettings: (data: { to_email: string }) =>
api.post<unknown, { success: boolean; message: string }>('/settings/system/smtp/test', data),
}; };
export const projectApi = { export const projectApi = {
+59
View File
@@ -11,6 +11,65 @@ export interface User {
last_login: string; 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 { export interface Settings {
id: string; id: string;