feature:新增邮箱注册登录功能
This commit is contained in:
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|
||||||
# 设置 Cookie(2小时有效)
|
|
||||||
max_age = settings.SESSION_EXPIRE_MINUTES * 60
|
|
||||||
response.set_cookie(
|
|
||||||
key="user_id",
|
|
||||||
value=user.user_id,
|
|
||||||
max_age=max_age,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 设置过期时间戳 Cookie(用于前端判断)
|
|
||||||
china_now = get_china_now()
|
|
||||||
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
|
|
||||||
expire_at = int(expire_time.timestamp())
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
# 临时存储 state(5分钟有效)
|
|
||||||
_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 Cookie(2小时有效)
|
_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)
|
||||||
|
|
||||||
# 设置 Cookie(2小时有效)
|
|
||||||
max_age = settings.SESSION_EXPIRE_MINUTES * 60
|
|
||||||
response.set_cookie(
|
|
||||||
key="user_id",
|
|
||||||
value=target_user.user_id,
|
|
||||||
max_age=max_age,
|
|
||||||
httponly=True,
|
|
||||||
samesite="lax"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 设置过期时间戳 Cookie(用于前端判断)
|
|
||||||
china_now = get_china_now()
|
|
||||||
expire_time = china_now + timedelta(minutes=settings.SESSION_EXPIRE_MINUTES)
|
|
||||||
expire_at = int(expire_time.timestamp())
|
|
||||||
|
|
||||||
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
@@ -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,
|
||||||
|
|||||||
@@ -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" # 云端服务地址
|
||||||
|
|||||||
@@ -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="更新时间")
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+655
-101
@@ -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 = () => {
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await authApi.localLogin(values.username, values.password);
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
message.success('登录成功!');
|
message.success('登录成功!');
|
||||||
|
|
||||||
// 检查是否永久隐藏公告
|
|
||||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||||
const today = new Date().toDateString();
|
const today = new Date().toDateString();
|
||||||
|
|
||||||
// 如果永久隐藏或今日已隐藏,则不显示公告
|
|
||||||
if (hideForever === 'true' || hideToday === today) {
|
if (hideForever === 'true' || hideToday === today) {
|
||||||
const redirect = searchParams.get('redirect') || '/';
|
const redirect = searchParams.get('redirect') || '/';
|
||||||
navigate(redirect);
|
navigate(redirect);
|
||||||
} else {
|
} else {
|
||||||
setShowAnnouncement(true);
|
setShowAnnouncement(true);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLocalLogin = async (values: LocalLoginValues) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await authApi.localLogin(values.username, values.password);
|
||||||
|
if (response.success) {
|
||||||
|
handleLoginSuccess();
|
||||||
}
|
}
|
||||||
} 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,37 +300,77 @@ 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
|
||||||
form={form}
|
form={localForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={handleLocalLogin}
|
onFinish={handleLocalLogin}
|
||||||
size="large"
|
size="large"
|
||||||
style={{ marginTop: '16px' }}
|
style={{ marginTop: 16 }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="username"
|
name="username"
|
||||||
label="管理账号"
|
label="管理账号"
|
||||||
rules={[{ required: true, message: '请输入管理账号' }]}
|
rules={[{ required: true, message: '请输入管理账号/邮箱' }]}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
|
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
|
||||||
placeholder="请输入管理账号"
|
placeholder="请输入管理账号/邮箱"
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
style={{ height: 46, borderRadius: 12 }}
|
style={{ height: 46, borderRadius: 12 }}
|
||||||
/>
|
/>
|
||||||
@@ -162,15 +407,322 @@ export default function Login() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</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={emailRegisterForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleEmailRegister}
|
||||||
|
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: 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
|
||||||
|
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
|
||||||
|
placeholder="选填,默认使用邮箱前缀"
|
||||||
|
autoComplete="nickname"
|
||||||
|
style={{ height: 46, borderRadius: 12 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
label="登录密码"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入登录密码' },
|
||||||
|
{ min: 6, message: '密码长度至少为 6 个字符' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
|
||||||
|
placeholder="请输入登录密码"
|
||||||
|
autoComplete="new-password"
|
||||||
|
style={{ height: 46, borderRadius: 12 }}
|
||||||
|
/>
|
||||||
|
</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 }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
|
||||||
|
验证码将发送到你填写的邮箱,若未收到请检查垃圾箱或稍后重试。注册后可通过邮箱验证码登录,也支持邮箱重置密码。
|
||||||
|
</Text>
|
||||||
|
</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}
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +399,12 @@ export default function ProjectList() {
|
|||||||
? '拆书导入'
|
? '拆书导入'
|
||||||
: activeView === 'mcp'
|
: activeView === 'mcp'
|
||||||
? 'MCP 插件'
|
? 'MCP 插件'
|
||||||
|
: activeView === 'system-settings'
|
||||||
|
? '系统设置'
|
||||||
: 'API 设置';
|
: 'API 设置';
|
||||||
|
|
||||||
|
const isAdmin = !!currentUser?.is_admin;
|
||||||
|
|
||||||
const sideMenuItems = [
|
const sideMenuItems = [
|
||||||
{
|
{
|
||||||
key: 'projects',
|
key: 'projects',
|
||||||
@@ -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 />}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user