更新
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""用户可添加抖音账号数量限制(限制功能已移除,保留接口兼容)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import Account, User
|
||||
from .roles import is_admin
|
||||
|
||||
UNLIMITED_ACCOUNTS = -1
|
||||
|
||||
|
||||
def normalize_max_accounts(value: int | None, role: str = "operator") -> int:
|
||||
if is_admin(role):
|
||||
return UNLIMITED_ACCOUNTS
|
||||
if value is None:
|
||||
return 3
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 3
|
||||
if parsed < 0:
|
||||
return UNLIMITED_ACCOUNTS
|
||||
return parsed
|
||||
|
||||
|
||||
def account_limit_for_user(user: User) -> int | None:
|
||||
"""账号数量/并发限制已移除,始终不限制。"""
|
||||
return None
|
||||
|
||||
|
||||
async def count_user_accounts(db: AsyncSession, user_id: int) -> int:
|
||||
result = await db.execute(
|
||||
select(func.count()).select_from(Account).where(Account.owner_id == user_id)
|
||||
)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
|
||||
async def count_user_account_breakdown(db: AsyncSession, user_id: int) -> dict[str, int]:
|
||||
"""统计用户名下托管账号:总数 / 可用 / 额度停用。"""
|
||||
total = await count_user_accounts(db, user_id)
|
||||
if total <= 0:
|
||||
return {"total": 0, "active": 0, "disabled": 0}
|
||||
disabled_result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Account)
|
||||
.where(Account.owner_id == user_id, Account.quota_disabled.is_(True))
|
||||
)
|
||||
disabled = int(disabled_result.scalar() or 0)
|
||||
active = max(0, total - disabled)
|
||||
return {"total": total, "active": active, "disabled": disabled}
|
||||
|
||||
|
||||
async def ensure_can_add_account(db: AsyncSession, user: User) -> None:
|
||||
"""账号数量限制已移除,任何用户可添加任意数量账号。"""
|
||||
return
|
||||
@@ -0,0 +1,68 @@
|
||||
"""账号额度与 quota_disabled 同步。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import Account, User
|
||||
from .account_limits import account_limit_for_user
|
||||
|
||||
QUOTA_DISABLED_MESSAGE = "账号额度不足,该账号已被系统停用"
|
||||
|
||||
|
||||
async def sync_user_account_quota(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
stop_worker: Callable[[int], Awaitable[None]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""按 max_accounts 同步停用状态:保留最早创建的账号,超额账号禁用并停止托管。"""
|
||||
limit = account_limit_for_user(user)
|
||||
|
||||
result = await db.execute(
|
||||
select(Account)
|
||||
.where(Account.owner_id == user.id)
|
||||
.order_by(Account.created_at.asc(), Account.id.asc())
|
||||
)
|
||||
accounts = list(result.scalars().all())
|
||||
|
||||
disabled_ids: list[int] = []
|
||||
enabled_ids: list[int] = []
|
||||
|
||||
if limit is None:
|
||||
for acc in accounts:
|
||||
if acc.quota_disabled:
|
||||
acc.quota_disabled = False
|
||||
if (acc.error_message or "").strip() == QUOTA_DISABLED_MESSAGE:
|
||||
acc.error_message = None
|
||||
enabled_ids.append(acc.id)
|
||||
return {"disabled_ids": disabled_ids, "enabled_ids": enabled_ids, "limit": None}
|
||||
|
||||
for idx, acc in enumerate(accounts):
|
||||
if idx < limit:
|
||||
if acc.quota_disabled:
|
||||
acc.quota_disabled = False
|
||||
if (acc.error_message or "").strip() == QUOTA_DISABLED_MESSAGE:
|
||||
acc.error_message = None
|
||||
enabled_ids.append(acc.id)
|
||||
else:
|
||||
if not acc.quota_disabled:
|
||||
acc.quota_disabled = True
|
||||
acc.error_message = QUOTA_DISABLED_MESSAGE
|
||||
acc.status = "offline"
|
||||
acc.qr_code_base64 = None
|
||||
disabled_ids.append(acc.id)
|
||||
if stop_worker:
|
||||
await stop_worker(acc.id)
|
||||
|
||||
return {"disabled_ids": disabled_ids, "enabled_ids": enabled_ids, "limit": limit}
|
||||
|
||||
|
||||
async def default_stop_worker(account_id: int) -> None:
|
||||
from main import manager
|
||||
|
||||
await manager.stop_worker(account_id)
|
||||
@@ -0,0 +1,50 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.database import get_db
|
||||
from models.models import User
|
||||
from .jwt_utils import decode_access_token
|
||||
from .roles import can_manage_users, can_write, is_admin
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if not credentials or not credentials.credentials:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录或令牌缺失")
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if not payload or not payload.get("sub"):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期,请重新登录")
|
||||
try:
|
||||
user_id = int(payload["sub"])
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效令牌")
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not is_admin(user.role):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||
return user
|
||||
|
||||
|
||||
async def require_write(user: User = Depends(get_current_user)) -> User:
|
||||
if not can_write(user.role):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="当前角色只读,无法执行此操作")
|
||||
return user
|
||||
|
||||
|
||||
async def require_user_manager(user: User = Depends(get_current_user)) -> User:
|
||||
if not can_manage_users(user.role):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
|
||||
return user
|
||||
@@ -0,0 +1,378 @@
|
||||
"""发送邮箱验证邮件(标准库 SMTP)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
import ssl
|
||||
from dataclasses import asdict
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Any
|
||||
|
||||
from .email_templates import build_password_reset_content, build_test_email_content, build_verify_email_content
|
||||
from .system_settings import SystemSettingsData
|
||||
|
||||
logger = logging.getLogger("auth.email")
|
||||
|
||||
SMTP_TIMEOUT_SECONDS = 45
|
||||
# 阿里企业邮自定义域名 SMTP(如 smtp.xxiaw.com)常出现 535,官方地址可正常认证
|
||||
ALIBABA_SMTP_OFFICIAL = "smtp.mxhichina.com"
|
||||
_SSL_CONTEXT = ssl.create_default_context()
|
||||
|
||||
|
||||
def _ssl_context(*, insecure: bool = False) -> ssl.SSLContext:
|
||||
if not insecure:
|
||||
return _SSL_CONTEXT
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
|
||||
|
||||
def _connect_ssl(host: str, port: int) -> smtplib.SMTP:
|
||||
try:
|
||||
return smtplib.SMTP_SSL(
|
||||
host,
|
||||
port,
|
||||
timeout=SMTP_TIMEOUT_SECONDS,
|
||||
context=_ssl_context(insecure=False),
|
||||
)
|
||||
except ssl.SSLCertVerificationError:
|
||||
logger.warning(
|
||||
"SMTP SSL certificate hostname mismatch for %s, retry without verify",
|
||||
host,
|
||||
)
|
||||
return smtplib.SMTP_SSL(
|
||||
host,
|
||||
port,
|
||||
timeout=SMTP_TIMEOUT_SECONDS,
|
||||
context=_ssl_context(insecure=True),
|
||||
)
|
||||
|
||||
|
||||
def _starttls(server: smtplib.SMTP, host: str) -> None:
|
||||
try:
|
||||
server.starttls(context=_ssl_context(insecure=False))
|
||||
except ssl.SSLCertVerificationError:
|
||||
logger.warning(
|
||||
"SMTP STARTTLS certificate hostname mismatch for %s, retry without verify",
|
||||
host,
|
||||
)
|
||||
server.starttls(context=_ssl_context(insecure=True))
|
||||
|
||||
|
||||
def build_verification_link(token: str, settings: SystemSettingsData) -> str:
|
||||
return f"{settings.app_url_normalized()}/#/login?verify_token={token}"
|
||||
|
||||
|
||||
def build_password_reset_link(token: str, settings: SystemSettingsData) -> str:
|
||||
return f"{settings.app_url_normalized()}/#/login?reset_token={token}"
|
||||
|
||||
|
||||
def resolve_smtp_mode(settings: SystemSettingsData) -> tuple[int, bool, bool]:
|
||||
"""返回 (port, use_ssl, use_starttls)。"""
|
||||
port = int(settings.smtp_port or 587)
|
||||
use_ssl = bool(settings.smtp_use_ssl) or port == 465
|
||||
# 465 只能 implicit SSL,绝不能 plain + STARTTLS
|
||||
if port == 465:
|
||||
use_ssl = True
|
||||
use_starttls = False
|
||||
elif port == 587:
|
||||
use_ssl = False
|
||||
use_starttls = bool(settings.smtp_use_tls)
|
||||
else:
|
||||
use_ssl = bool(settings.smtp_use_ssl)
|
||||
use_starttls = bool(settings.smtp_use_tls) and not use_ssl
|
||||
return port, use_ssl, use_starttls
|
||||
|
||||
|
||||
def _open_smtp(settings: SystemSettingsData) -> smtplib.SMTP:
|
||||
host = settings.smtp_host.strip()
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
logger.info(
|
||||
"SMTP connect host=%s port=%s ssl=%s starttls=%s user=%s",
|
||||
host,
|
||||
port,
|
||||
use_ssl,
|
||||
use_starttls,
|
||||
settings.smtp_user or "",
|
||||
)
|
||||
|
||||
if use_ssl:
|
||||
server = _connect_ssl(host, port)
|
||||
server.ehlo()
|
||||
return server
|
||||
|
||||
server = smtplib.SMTP(host, port, timeout=SMTP_TIMEOUT_SECONDS)
|
||||
server.ehlo()
|
||||
if use_starttls:
|
||||
_starttls(server, host)
|
||||
server.ehlo()
|
||||
return server
|
||||
|
||||
|
||||
def _close_smtp(server: smtplib.SMTP) -> None:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
try:
|
||||
server.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _login_smtp(server: smtplib.SMTP, settings: SystemSettingsData) -> tuple[smtplib.SMTP, bool]:
|
||||
"""登录 SMTP,返回 (server, 是否使用了阿里官方地址回退)。"""
|
||||
user = (settings.smtp_user or "").strip()
|
||||
pwd = settings.smtp_password
|
||||
if not user:
|
||||
return server, False
|
||||
if not pwd:
|
||||
raise RuntimeError("未填写 SMTP 密码,请在设置中填写登录密码或客户端安全密码后重试")
|
||||
try:
|
||||
server.login(user, pwd)
|
||||
return server, False
|
||||
except smtplib.SMTPAuthenticationError as exc:
|
||||
host = (settings.smtp_host or "").strip().lower()
|
||||
if host == ALIBABA_SMTP_OFFICIAL:
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from exc
|
||||
logger.warning(
|
||||
"SMTP auth failed on %s (%s), retrying via %s",
|
||||
host,
|
||||
exc,
|
||||
ALIBABA_SMTP_OFFICIAL,
|
||||
)
|
||||
_close_smtp(server)
|
||||
fallback = SystemSettingsData(**{**asdict(settings), "smtp_host": ALIBABA_SMTP_OFFICIAL})
|
||||
fallback_server = _open_smtp(fallback)
|
||||
try:
|
||||
fallback_server.login(user, pwd)
|
||||
except Exception as retry_exc:
|
||||
_close_smtp(fallback_server)
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from retry_exc
|
||||
return fallback_server, True
|
||||
|
||||
|
||||
def _format_login_error(exc: Exception, settings: SystemSettingsData) -> str:
|
||||
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
||||
return _format_smtp_error(exc, settings)
|
||||
if isinstance(exc, smtplib.SMTPServerDisconnected):
|
||||
return (
|
||||
"SMTP 服务器在登录时断开连接,通常不是加密方式问题,而是密码错误或账号未授权 SMTP。"
|
||||
f"请确认用户名 {settings.smtp_user or ''} 与密码/客户端安全密码正确,"
|
||||
"并在阿里企业邮后台开启「允许使用第三方客户端」。"
|
||||
"若使用自定义域名 SMTP(如 smtp.xxiaw.com)仍失败,可改用 smtp.mxhichina.com 后重试。"
|
||||
)
|
||||
return _format_smtp_error(exc, settings)
|
||||
|
||||
|
||||
def _format_smtp_error(exc: Exception, settings: SystemSettingsData) -> str:
|
||||
msg = str(exc).strip() or exc.__class__.__name__
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
hints: list[str] = []
|
||||
|
||||
if isinstance(exc, ssl.SSLCertVerificationError):
|
||||
hints.append(
|
||||
f"SSL 证书域名与 {settings.smtp_host} 不匹配(阿里企业邮自定义域名常见),"
|
||||
"系统已尝试跳过证书校验;若仍失败请改用 smtp.mxhichina.com"
|
||||
)
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
|
||||
if isinstance(exc, smtplib.SMTPAuthenticationError):
|
||||
host = (settings.smtp_host or "").strip().lower()
|
||||
if host != ALIBABA_SMTP_OFFICIAL and host.startswith("smtp."):
|
||||
hints.append(
|
||||
f"若使用自定义域名 SMTP({settings.smtp_host}),阿里企业邮常返回 535;"
|
||||
f"请将 SMTP 服务器改为 {ALIBABA_SMTP_OFFICIAL} 后重试"
|
||||
)
|
||||
hints.append(
|
||||
"SMTP 登录失败:请确认登录用户名为完整邮箱地址,"
|
||||
"密码为邮箱登录密码或阿里企业邮「客户端安全密码」(非网页登录密码时需在邮箱设置中单独生成)"
|
||||
)
|
||||
hints.append("请在阿里企业邮管理后台确认已开启「允许使用第三方客户端」")
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
|
||||
lower = msg.lower()
|
||||
if "connection unexpectedly closed" in lower and port == 465 and not use_ssl:
|
||||
hints.append("465 端口必须使用 SSL 加密,不能勾选 STARTTLS")
|
||||
elif "connection unexpectedly closed" in lower and port == 465:
|
||||
if use_ssl:
|
||||
hints.append(
|
||||
"连接已建立但在后续步骤失败:请重点检查 SMTP 密码/客户端安全密码,"
|
||||
"或尝试将服务器改为 smtp.mxhichina.com"
|
||||
)
|
||||
else:
|
||||
hints.append("465 端口必须使用 SSL 加密,请选择「SSL / TLS(端口 465)」")
|
||||
if "timed out" in lower or "timeout" in lower or "10060" in lower:
|
||||
if port == 587:
|
||||
hints.append(
|
||||
f"587 端口 STARTTLS 连接 {settings.smtp_host} 超时:"
|
||||
"该服务器可能仅支持 465 SSL,请将端口改为 465 并选择 SSL 加密"
|
||||
)
|
||||
else:
|
||||
hints.append(
|
||||
f"无法在 {SMTP_TIMEOUT_SECONDS}s 内连接 {settings.smtp_host}:{port},"
|
||||
"请检查服务器地址、端口、防火墙/安全组是否放行出站 SMTP"
|
||||
)
|
||||
if "authentication" in lower or "535" in lower:
|
||||
hints.append("认证失败:请检查 SMTP 密码/客户端安全密码是否正确")
|
||||
|
||||
if hints:
|
||||
return f"{msg}。{' '.join(hints)}"
|
||||
return msg
|
||||
|
||||
|
||||
def diagnose_smtp(settings: SystemSettingsData) -> dict[str, Any]:
|
||||
"""分步检测 SMTP 连接,便于定位配置问题。"""
|
||||
host = (settings.smtp_host or "").strip()
|
||||
port, use_ssl, use_starttls = resolve_smtp_mode(settings)
|
||||
result: dict[str, Any] = {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"mode": "ssl" if use_ssl else ("starttls" if use_starttls else "plain"),
|
||||
"steps": [],
|
||||
"ok": False,
|
||||
}
|
||||
if not host:
|
||||
result["steps"].append({"step": "config", "ok": False, "message": "未填写 SMTP 服务器"})
|
||||
return result
|
||||
|
||||
server: smtplib.SMTP | None = None
|
||||
try:
|
||||
server = _open_smtp(settings)
|
||||
result["steps"].append({"step": "connect", "ok": True, "message": "连接成功"})
|
||||
except Exception as exc:
|
||||
result["steps"].append(
|
||||
{"step": "connect", "ok": False, "message": _format_smtp_error(exc, settings)}
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
if settings.smtp_user:
|
||||
if not settings.smtp_password:
|
||||
raise RuntimeError("未填写 SMTP 密码,请在设置中填写登录密码或客户端安全密码后重试")
|
||||
host_before = (settings.smtp_host or "").strip().lower()
|
||||
server, used_fallback = _login_smtp(server, settings)
|
||||
login_msg = "登录成功"
|
||||
if used_fallback and host_before != ALIBABA_SMTP_OFFICIAL:
|
||||
login_msg = (
|
||||
f"登录成功(已通过 {ALIBABA_SMTP_OFFICIAL} 认证,"
|
||||
f"建议将 SMTP 服务器改为 {ALIBABA_SMTP_OFFICIAL})"
|
||||
)
|
||||
result["steps"].append({"step": "login", "ok": True, "message": login_msg})
|
||||
else:
|
||||
result["steps"].append({"step": "login", "ok": True, "message": "未配置用户名,跳过登录"})
|
||||
result["ok"] = True
|
||||
except Exception as exc:
|
||||
result["steps"].append(
|
||||
{"step": "login", "ok": False, "message": _format_login_error(exc, settings)}
|
||||
)
|
||||
finally:
|
||||
if server:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
server.close()
|
||||
return result
|
||||
|
||||
|
||||
def _send_sync(
|
||||
settings: SystemSettingsData,
|
||||
to_email: str,
|
||||
subject: str,
|
||||
html_body: str,
|
||||
text_body: str,
|
||||
) -> None:
|
||||
if not settings.smtp_configured():
|
||||
raise RuntimeError("SMTP 未配置,无法发送邮件")
|
||||
|
||||
smtp_from = (settings.smtp_from or settings.smtp_user or "").strip()
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = smtp_from
|
||||
msg["To"] = to_email
|
||||
msg.attach(MIMEText(text_body, "plain", "utf-8"))
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
server = _open_smtp(settings)
|
||||
try:
|
||||
if settings.smtp_user:
|
||||
try:
|
||||
server, _ = _login_smtp(server, settings)
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(_format_login_error(exc, settings)) from exc
|
||||
server.sendmail(smtp_from, [to_email], msg.as_string())
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(_format_smtp_error(exc, settings)) from exc
|
||||
finally:
|
||||
try:
|
||||
server.quit()
|
||||
except Exception:
|
||||
server.close()
|
||||
|
||||
|
||||
async def send_password_reset_email(
|
||||
to_email: str,
|
||||
username: str,
|
||||
token: str,
|
||||
settings: SystemSettingsData,
|
||||
) -> str:
|
||||
"""发送密码重置邮件,返回重置链接。"""
|
||||
link = build_password_reset_link(token, settings)
|
||||
expire_hours = int(settings.email_verify_token_hours or 24)
|
||||
subject, text_body, html_body = build_password_reset_content(
|
||||
settings, username, to_email, link, expire_hours
|
||||
)
|
||||
|
||||
if settings.smtp_configured():
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
logger.info("Password reset email sent to %s", to_email)
|
||||
else:
|
||||
logger.warning(
|
||||
"SMTP 未配置,密码重置链接: user=%s email=%s link=%s",
|
||||
username,
|
||||
to_email,
|
||||
link,
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
async def send_verification_email(
|
||||
to_email: str,
|
||||
username: str,
|
||||
token: str,
|
||||
settings: SystemSettingsData,
|
||||
) -> str:
|
||||
"""发送验证邮件,返回验证链接。"""
|
||||
link = build_verification_link(token, settings)
|
||||
subject, text_body, html_body = build_verify_email_content(
|
||||
settings, username, to_email, link
|
||||
)
|
||||
|
||||
if settings.smtp_configured():
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
logger.info("Verification email sent to %s", to_email)
|
||||
else:
|
||||
logger.warning(
|
||||
"SMTP 未配置,验证链接: user=%s email=%s link=%s",
|
||||
username,
|
||||
to_email,
|
||||
link,
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
async def send_test_email(to_email: str, settings: SystemSettingsData) -> None:
|
||||
diagnosis = await asyncio.to_thread(diagnose_smtp, settings)
|
||||
if not diagnosis.get("ok"):
|
||||
failed = next((s for s in diagnosis.get("steps", []) if not s.get("ok")), None)
|
||||
raise RuntimeError(failed["message"] if failed else "SMTP 连接检测失败")
|
||||
|
||||
subject, text_body, html_body = build_test_email_content(settings, to_email)
|
||||
await asyncio.to_thread(_send_sync, settings, to_email, subject, html_body, text_body)
|
||||
@@ -0,0 +1,103 @@
|
||||
"""邮件模板渲染。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
|
||||
from .system_settings import (
|
||||
APP_NAME,
|
||||
DEFAULT_EMAIL_TEST_BODY,
|
||||
DEFAULT_EMAIL_TEST_HTML,
|
||||
DEFAULT_EMAIL_TEST_SUBJECT,
|
||||
DEFAULT_EMAIL_VERIFY_BODY,
|
||||
DEFAULT_EMAIL_VERIFY_HTML,
|
||||
DEFAULT_EMAIL_VERIFY_SUBJECT,
|
||||
DEFAULT_PASSWORD_RESET_BODY,
|
||||
DEFAULT_PASSWORD_RESET_HTML,
|
||||
DEFAULT_PASSWORD_RESET_SUBJECT,
|
||||
SystemSettingsData,
|
||||
)
|
||||
|
||||
_TEMPLATE_VAR_PATTERN = re.compile(r"\{(\w+)\}")
|
||||
|
||||
|
||||
def render_email_template(template: str, **context: str) -> str:
|
||||
"""安全渲染邮件模板,未知占位符保留原样。"""
|
||||
value = template or ""
|
||||
|
||||
def replacer(match: re.Match) -> str:
|
||||
key = match.group(1)
|
||||
return context.get(key, match.group(0))
|
||||
|
||||
return _TEMPLATE_VAR_PATTERN.sub(replacer, value)
|
||||
|
||||
|
||||
def _text_to_html(text: str) -> str:
|
||||
escaped = html.escape(text or "")
|
||||
return f'<pre style="font-family:sans-serif;white-space:pre-wrap;margin:0;">{escaped}</pre>'
|
||||
|
||||
|
||||
def build_verify_email_content(
|
||||
settings: SystemSettingsData,
|
||||
username: str,
|
||||
to_email: str,
|
||||
link: str,
|
||||
) -> tuple[str, str, str]:
|
||||
context = {
|
||||
"username": username,
|
||||
"email": to_email,
|
||||
"link": link,
|
||||
"app_name": APP_NAME,
|
||||
}
|
||||
subject_tpl = settings.email_verify_subject or DEFAULT_EMAIL_VERIFY_SUBJECT
|
||||
body_tpl = settings.email_verify_body or DEFAULT_EMAIL_VERIFY_BODY
|
||||
html_tpl = settings.email_verify_html or ""
|
||||
|
||||
subject = render_email_template(subject_tpl, **context)
|
||||
text_body = render_email_template(body_tpl, **context)
|
||||
if html_tpl.strip():
|
||||
html_body = render_email_template(html_tpl, **context)
|
||||
else:
|
||||
html_body = _text_to_html(text_body)
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def build_password_reset_content(
|
||||
settings: SystemSettingsData,
|
||||
username: str,
|
||||
to_email: str,
|
||||
link: str,
|
||||
expire_hours: int,
|
||||
) -> tuple[str, str, str]:
|
||||
context = {
|
||||
"username": username,
|
||||
"email": to_email,
|
||||
"link": link,
|
||||
"app_name": APP_NAME,
|
||||
"expire_hours": str(expire_hours),
|
||||
}
|
||||
subject = render_email_template(DEFAULT_PASSWORD_RESET_SUBJECT, **context)
|
||||
text_body = render_email_template(DEFAULT_PASSWORD_RESET_BODY, **context)
|
||||
html_body = render_email_template(DEFAULT_PASSWORD_RESET_HTML, **context)
|
||||
return subject, text_body, html_body
|
||||
|
||||
|
||||
def build_test_email_content(settings: SystemSettingsData, to_email: str) -> tuple[str, str, str]:
|
||||
context = {
|
||||
"username": "测试用户",
|
||||
"email": to_email,
|
||||
"link": settings.app_url_normalized(),
|
||||
"app_name": APP_NAME,
|
||||
}
|
||||
subject_tpl = settings.email_test_subject or DEFAULT_EMAIL_TEST_SUBJECT
|
||||
body_tpl = settings.email_test_body or DEFAULT_EMAIL_TEST_BODY
|
||||
html_tpl = settings.email_test_html or ""
|
||||
|
||||
subject = render_email_template(subject_tpl, **context)
|
||||
text_body = render_email_template(body_tpl, **context)
|
||||
if html_tpl.strip():
|
||||
html_body = render_email_template(html_tpl, **context)
|
||||
else:
|
||||
html_body = _text_to_html(text_body)
|
||||
return subject, text_body, html_body
|
||||
@@ -0,0 +1,78 @@
|
||||
"""邮箱验证令牌创建与校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import EmailVerificationToken, User
|
||||
|
||||
|
||||
def _token_expires_at(hours: int) -> datetime:
|
||||
return datetime.utcnow() + timedelta(hours=max(1, min(168, int(hours or 24))))
|
||||
|
||||
|
||||
async def invalidate_user_tokens(db: AsyncSession, user_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(EmailVerificationToken).where(EmailVerificationToken.user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
async def create_verification_token(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
expire_hours: int = 24,
|
||||
) -> str:
|
||||
await invalidate_user_tokens(db, user.id)
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
EmailVerificationToken(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=_token_expires_at(expire_hours),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
|
||||
async def verify_email_token(db: AsyncSession, token: str) -> User | None:
|
||||
value = (token or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(EmailVerificationToken, User)
|
||||
.join(User, User.id == EmailVerificationToken.user_id)
|
||||
.where(EmailVerificationToken.token == value)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
record, user = row
|
||||
if record.expires_at < datetime.utcnow():
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
|
||||
def mask_email(email: str) -> str:
|
||||
value = (email or "").strip()
|
||||
if "@" not in value:
|
||||
return value
|
||||
local, domain = value.split("@", 1)
|
||||
if len(local) <= 2:
|
||||
masked_local = local[0] + "*"
|
||||
else:
|
||||
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
|
||||
return f"{masked_local}@{domain}"
|
||||
@@ -0,0 +1,24 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
SECRET_KEY = os.getenv("KEFU_SECRET_KEY", "kefu-dev-secret-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("KEFU_TOKEN_EXPIRE_MINUTES", str(60 * 24)))
|
||||
|
||||
|
||||
def create_access_token(subject: str, extra: Optional[dict[str, Any]] = None) -> str:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
if extra:
|
||||
payload.update(extra)
|
||||
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict[str, Any]]:
|
||||
try:
|
||||
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -0,0 +1,64 @@
|
||||
"""密码重置令牌创建与校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import PasswordResetToken, User
|
||||
|
||||
|
||||
def _token_expires_at(hours: int) -> datetime:
|
||||
return datetime.utcnow() + timedelta(hours=max(1, min(168, int(hours or 24))))
|
||||
|
||||
|
||||
async def invalidate_user_reset_tokens(db: AsyncSession, user_id: int) -> None:
|
||||
await db.execute(
|
||||
delete(PasswordResetToken).where(PasswordResetToken.user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
async def create_password_reset_token(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
expire_hours: int = 24,
|
||||
) -> str:
|
||||
await invalidate_user_reset_tokens(db, user.id)
|
||||
token = secrets.token_urlsafe(32)
|
||||
db.add(
|
||||
PasswordResetToken(
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
expires_at=_token_expires_at(expire_hours),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return token
|
||||
|
||||
|
||||
async def verify_password_reset_token(db: AsyncSession, token: str) -> User | None:
|
||||
value = (token or "").strip()
|
||||
if not value:
|
||||
return None
|
||||
|
||||
result = await db.execute(
|
||||
select(PasswordResetToken, User)
|
||||
.join(User, User.id == PasswordResetToken.user_id)
|
||||
.where(PasswordResetToken.token == value)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
record, user = row
|
||||
if record.expires_at < datetime.utcnow():
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return user
|
||||
@@ -0,0 +1,11 @@
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import Iterable
|
||||
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_OPERATOR = "operator"
|
||||
ROLE_VIEWER = "viewer"
|
||||
|
||||
ALL_ROLES = (ROLE_ADMIN, ROLE_OPERATOR, ROLE_VIEWER)
|
||||
|
||||
ROLE_LABELS = {
|
||||
ROLE_ADMIN: "管理员",
|
||||
ROLE_OPERATOR: "运营",
|
||||
ROLE_VIEWER: "只读",
|
||||
}
|
||||
|
||||
|
||||
def is_admin(role: str) -> bool:
|
||||
return role == ROLE_ADMIN
|
||||
|
||||
|
||||
def can_write(role: str) -> bool:
|
||||
return role in (ROLE_ADMIN, ROLE_OPERATOR)
|
||||
|
||||
|
||||
def can_manage_users(role: str) -> bool:
|
||||
return role == ROLE_ADMIN
|
||||
|
||||
|
||||
def ensure_role(role: str) -> str:
|
||||
if role not in ALL_ROLES:
|
||||
raise ValueError(f"无效角色: {role}")
|
||||
return role
|
||||
@@ -0,0 +1,511 @@
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.database import get_db
|
||||
from models.models import User
|
||||
from .account_limits import (
|
||||
UNLIMITED_ACCOUNTS,
|
||||
count_user_account_breakdown,
|
||||
normalize_max_accounts,
|
||||
)
|
||||
from .account_quota import default_stop_worker, sync_user_account_quota
|
||||
from .dependencies import get_current_user, require_user_manager
|
||||
from .email_service import (
|
||||
build_password_reset_link,
|
||||
build_verification_link,
|
||||
send_password_reset_email,
|
||||
send_verification_email,
|
||||
)
|
||||
from .email_verification import create_verification_token, mask_email, verify_email_token
|
||||
from .password_reset import create_password_reset_token, verify_password_reset_token
|
||||
from .jwt_utils import create_access_token
|
||||
from .passwords import hash_password, verify_password
|
||||
from .roles import ALL_ROLES, ROLE_LABELS, ROLE_OPERATOR, ensure_role, is_admin
|
||||
from .schemas import (
|
||||
LoginRequest,
|
||||
MessageResponse,
|
||||
ForgotPasswordRequest,
|
||||
ForgotPasswordResponse,
|
||||
RegisterRequest,
|
||||
RegisterResponse,
|
||||
ResendVerificationRequest,
|
||||
ResetPasswordRequest,
|
||||
RolesResponse,
|
||||
RoleInfo,
|
||||
TokenResponse,
|
||||
UserCreate,
|
||||
UserResponse,
|
||||
UserUpdate,
|
||||
VerifyEmailRequest,
|
||||
)
|
||||
from .system_settings import SystemSettingsData, load_settings
|
||||
|
||||
logger = logging.getLogger("auth.router")
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
async def _build_user_response(db: AsyncSession, user: User, with_count: bool = False) -> UserResponse:
|
||||
payload = UserResponse.model_validate(user)
|
||||
if with_count:
|
||||
breakdown = await count_user_account_breakdown(db, user.id)
|
||||
payload.account_count = breakdown["total"]
|
||||
payload.active_account_count = breakdown["active"]
|
||||
payload.disabled_account_count = breakdown["disabled"]
|
||||
return payload
|
||||
|
||||
|
||||
async def _ensure_email_available(
|
||||
db: AsyncSession,
|
||||
email: str | None,
|
||||
exclude_user_id: int | None = None,
|
||||
) -> str | None:
|
||||
value = (str(email).strip().lower() if email else "") or None
|
||||
if not value:
|
||||
return None
|
||||
stmt = select(User).where(User.email == value)
|
||||
if exclude_user_id:
|
||||
stmt = stmt.where(User.id != exclude_user_id)
|
||||
exists = await db.execute(stmt)
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="邮箱已被其他用户使用")
|
||||
return value
|
||||
|
||||
|
||||
def _apply_email_verified(user: User, verified: bool | None) -> None:
|
||||
if verified is None:
|
||||
return
|
||||
user.email_verified = verified
|
||||
if verified:
|
||||
if not user.email_verified_at:
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
else:
|
||||
user.email_verified_at = None
|
||||
|
||||
|
||||
def _email_not_verified_detail(user: User) -> dict:
|
||||
return {
|
||||
"code": "email_not_verified",
|
||||
"message": "邮箱尚未验证,请先完成邮箱验证后再登录",
|
||||
"email": mask_email(user.email or ""),
|
||||
}
|
||||
|
||||
|
||||
def _email_not_bound_detail() -> dict:
|
||||
return {
|
||||
"code": "email_not_bound",
|
||||
"message": "该账号未绑定邮箱,请联系管理员绑定邮箱后再登录",
|
||||
}
|
||||
|
||||
|
||||
def _require_email_access(user: User, settings: SystemSettingsData) -> None:
|
||||
if is_admin(user.role):
|
||||
return
|
||||
if settings.email_binding_required and not user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_email_not_bound_detail(),
|
||||
)
|
||||
if not settings.email_verification_required:
|
||||
return
|
||||
if user.email_verified:
|
||||
return
|
||||
if user.email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=_email_not_verified_detail(user),
|
||||
)
|
||||
|
||||
|
||||
async def _issue_token(user: User) -> TokenResponse:
|
||||
token = create_access_token(str(user.id), {"role": user.role, "username": user.username})
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
async def _send_user_verification(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
settings: SystemSettingsData,
|
||||
) -> tuple[bool, str | None]:
|
||||
if not user.email:
|
||||
raise HTTPException(status_code=400, detail="该账号未绑定邮箱")
|
||||
token = await create_verification_token(
|
||||
db, user, expire_hours=settings.email_verify_token_hours
|
||||
)
|
||||
await db.commit()
|
||||
link = build_verification_link(token, settings)
|
||||
if not settings.smtp_configured():
|
||||
logger.warning(
|
||||
"SMTP 未配置,验证链接: user=%s email=%s link=%s",
|
||||
user.username,
|
||||
user.email,
|
||||
link,
|
||||
)
|
||||
dev_url = link if settings.debug_show_verify_link else None
|
||||
return False, dev_url
|
||||
try:
|
||||
await send_verification_email(user.email, user.username, token, settings)
|
||||
return True, None
|
||||
except Exception as exc:
|
||||
logger.exception("Send verification email failed for user=%s", user.username)
|
||||
dev_url = link if settings.debug_show_verify_link else None
|
||||
if dev_url:
|
||||
return False, dev_url
|
||||
raise HTTPException(status_code=400, detail=f"邮件发送失败:{exc}") from exc
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
settings = await load_settings(db)
|
||||
result = await db.execute(select(User).where(User.username == body.username.strip()))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已禁用")
|
||||
_require_email_access(user, settings)
|
||||
return await _issue_token(user)
|
||||
|
||||
|
||||
@router.post("/register", response_model=RegisterResponse)
|
||||
async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
settings = await load_settings(db)
|
||||
if not settings.registration_enabled:
|
||||
raise HTTPException(status_code=403, detail="当前未开放用户注册")
|
||||
|
||||
username = body.username.strip()
|
||||
email = str(body.email).strip().lower()
|
||||
|
||||
exists = await db.execute(select(User).where(User.username == username))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
exists = await db.execute(select(User).where(User.email == email))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="邮箱已被注册")
|
||||
|
||||
verification_required = settings.email_verification_required
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name or username,
|
||||
role=ROLE_OPERATOR,
|
||||
is_active=True,
|
||||
email_verified=not verification_required,
|
||||
email_verified_at=datetime.utcnow() if not verification_required else None,
|
||||
max_accounts=max(0, int(settings.default_register_max_accounts or 3)),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
if not verification_required:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return RegisterResponse(
|
||||
message="注册成功,可直接登录",
|
||||
email=mask_email(user.email),
|
||||
verification_sent=False,
|
||||
verification_required=False,
|
||||
)
|
||||
|
||||
verification_sent, dev_url = await _send_user_verification(db, user, settings)
|
||||
await db.refresh(user)
|
||||
message = "注册成功,验证邮件已发送,请查收并完成验证后再登录"
|
||||
if not verification_sent:
|
||||
message = (
|
||||
"注册成功。验证邮件未能发出,请使用下方链接或联系管理员检查 SMTP 配置"
|
||||
if dev_url
|
||||
else "注册成功。邮件服务未配置或发送失败,请联系管理员"
|
||||
)
|
||||
|
||||
return RegisterResponse(
|
||||
message=message,
|
||||
email=mask_email(user.email),
|
||||
verification_sent=verification_sent,
|
||||
verification_required=True,
|
||||
dev_verify_url=dev_url,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email(body: VerifyEmailRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await verify_email_token(db, body.token.strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="验证链接无效或已过期")
|
||||
await db.commit()
|
||||
return MessageResponse(message="邮箱验证成功,现在可以登录了")
|
||||
|
||||
|
||||
@router.get("/verify-email", response_model=MessageResponse)
|
||||
async def verify_email_get(token: str = Query(..., min_length=8), db: AsyncSession = Depends(get_db)):
|
||||
user = await verify_email_token(db, token.strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="验证链接无效或已过期")
|
||||
await db.commit()
|
||||
return MessageResponse(message="邮箱验证成功,现在可以登录了")
|
||||
|
||||
|
||||
@router.post("/forgot-password", response_model=ForgotPasswordResponse)
|
||||
async def forgot_password(body: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
settings = await load_settings(db)
|
||||
if not settings.smtp_configured():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="邮件服务未配置,请联系管理员重置密码",
|
||||
)
|
||||
|
||||
email = str(body.email).strip().lower() if body.email else None
|
||||
username = body.username.strip() if body.username else None
|
||||
if not email and not username:
|
||||
raise HTTPException(status_code=400, detail="请提供邮箱或用户名")
|
||||
|
||||
user = None
|
||||
if email:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user and username:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
generic = ForgotPasswordResponse(
|
||||
message="若账号存在且已绑定邮箱,重置邮件将发送到注册邮箱",
|
||||
email=mask_email(user.email if user and user.email else (email or "")),
|
||||
reset_sent=True,
|
||||
)
|
||||
|
||||
if not user or not user.email or not user.is_active:
|
||||
return generic
|
||||
|
||||
token = await create_password_reset_token(
|
||||
db, user, expire_hours=settings.email_verify_token_hours
|
||||
)
|
||||
await db.commit()
|
||||
link = build_password_reset_link(token, settings)
|
||||
try:
|
||||
await send_password_reset_email(user.email, user.username, token, settings)
|
||||
except Exception as exc:
|
||||
logger.exception("Send password reset email failed for user=%s", user.username)
|
||||
dev_url = link if settings.debug_show_verify_link else None
|
||||
if dev_url:
|
||||
return ForgotPasswordResponse(
|
||||
message="邮件发送失败,请使用下方开发重置链接",
|
||||
email=mask_email(user.email),
|
||||
reset_sent=False,
|
||||
dev_reset_url=dev_url,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"邮件发送失败:{exc}") from exc
|
||||
return ForgotPasswordResponse(
|
||||
message="重置邮件已发送,请查收并按邮件说明设置新密码",
|
||||
email=mask_email(user.email),
|
||||
reset_sent=True,
|
||||
dev_reset_url=None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reset-password", response_model=MessageResponse)
|
||||
async def reset_password(body: ResetPasswordRequest, db: AsyncSession = Depends(get_db)):
|
||||
user = await verify_password_reset_token(db, body.token.strip())
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="重置链接无效或已过期")
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=400, detail="账号已禁用,请联系管理员")
|
||||
user.password_hash = hash_password(body.password)
|
||||
await db.commit()
|
||||
return MessageResponse(message="密码已重置,请使用新密码登录")
|
||||
|
||||
|
||||
@router.post("/resend-verification", response_model=RegisterResponse)
|
||||
async def resend_verification(body: ResendVerificationRequest, db: AsyncSession = Depends(get_db)):
|
||||
settings = await load_settings(db)
|
||||
if not settings.email_verification_required:
|
||||
raise HTTPException(status_code=400, detail="当前系统未开启邮箱验证")
|
||||
email = str(body.email).strip().lower() if body.email else None
|
||||
username = body.username.strip() if body.username else None
|
||||
if not email and not username:
|
||||
raise HTTPException(status_code=400, detail="请提供邮箱或用户名")
|
||||
|
||||
user = None
|
||||
if email:
|
||||
result = await db.execute(select(User).where(User.email == email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user and username:
|
||||
result = await db.execute(select(User).where(User.username == username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
return RegisterResponse(
|
||||
message="若账号存在且未验证,验证邮件将发送到注册邮箱",
|
||||
email=mask_email(email or ""),
|
||||
verification_sent=True,
|
||||
verification_required=True,
|
||||
)
|
||||
|
||||
if user.email_verified:
|
||||
raise HTTPException(status_code=400, detail="该账号邮箱已验证,可直接登录")
|
||||
|
||||
verification_sent, dev_url = await _send_user_verification(db, user, settings)
|
||||
message = (
|
||||
"验证邮件已重新发送,请查收"
|
||||
if verification_sent
|
||||
else "邮件未能发出,请使用下方验证链接或联系管理员检查 SMTP 配置"
|
||||
)
|
||||
return RegisterResponse(
|
||||
message=message,
|
||||
email=mask_email(user.email or ""),
|
||||
verification_sent=verification_sent,
|
||||
verification_required=True,
|
||||
dev_verify_url=dev_url,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await _build_user_response(db, user, with_count=True)
|
||||
|
||||
|
||||
@router.get("/roles", response_model=RolesResponse)
|
||||
async def list_roles(_: User = Depends(get_current_user)):
|
||||
return RolesResponse(
|
||||
roles=[RoleInfo(value=r, label=ROLE_LABELS.get(r, r)) for r in ALL_ROLES]
|
||||
)
|
||||
|
||||
|
||||
users_router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
@users_router.get("", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_user_manager),
|
||||
):
|
||||
result = await db.execute(select(User).order_by(User.id.asc()))
|
||||
users = result.scalars().all()
|
||||
responses = []
|
||||
for user in users:
|
||||
responses.append(await _build_user_response(db, user, with_count=True))
|
||||
return responses
|
||||
|
||||
|
||||
@users_router.post("", response_model=UserResponse)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_user_manager),
|
||||
):
|
||||
settings = await load_settings(db)
|
||||
exists = await db.execute(select(User).where(User.username == body.username))
|
||||
if exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
try:
|
||||
role = ensure_role(body.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
email = await _ensure_email_available(db, str(body.email) if body.email else None)
|
||||
if settings.email_binding_required and not is_admin(role) and not email:
|
||||
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,请填写邮箱")
|
||||
if email:
|
||||
email_verified = body.email_verified
|
||||
else:
|
||||
email_verified = not settings.email_binding_required
|
||||
user = User(
|
||||
username=body.username.strip(),
|
||||
password_hash=hash_password(body.password),
|
||||
display_name=body.display_name or body.username,
|
||||
role=role,
|
||||
is_active=True,
|
||||
email=email,
|
||||
email_verified=email_verified,
|
||||
email_verified_at=datetime.utcnow() if email and email_verified else None,
|
||||
max_accounts=normalize_max_accounts(body.max_accounts, role),
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return await _build_user_response(db, user, with_count=True)
|
||||
|
||||
|
||||
@users_router.put("/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current: User = Depends(require_user_manager),
|
||||
):
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
settings = await load_settings(db)
|
||||
if user.id == current.id and body.is_active is False:
|
||||
raise HTTPException(status_code=400, detail="不能禁用当前登录账号")
|
||||
if body.display_name is not None:
|
||||
user.display_name = body.display_name
|
||||
if body.role is not None:
|
||||
prev_role = user.role
|
||||
try:
|
||||
user.role = ensure_role(body.role)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if is_admin(user.role):
|
||||
user.max_accounts = UNLIMITED_ACCOUNTS
|
||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||
elif is_admin(prev_role) and not is_admin(user.role):
|
||||
settings = await load_settings(db)
|
||||
user.max_accounts = max(0, int(settings.default_register_max_accounts or 3))
|
||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||
if body.is_active is not None:
|
||||
user.is_active = body.is_active
|
||||
if body.password:
|
||||
user.password_hash = hash_password(body.password)
|
||||
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if "max_accounts" in updates and not is_admin(user.role):
|
||||
user.max_accounts = normalize_max_accounts(updates["max_accounts"], user.role)
|
||||
await sync_user_account_quota(db, user, stop_worker=default_stop_worker)
|
||||
if "email" in updates:
|
||||
raw_email = updates.get("email")
|
||||
user.email = await _ensure_email_available(
|
||||
db,
|
||||
str(raw_email) if raw_email else None,
|
||||
exclude_user_id=user.id,
|
||||
)
|
||||
if not user.email:
|
||||
user.email_verified = not (
|
||||
settings.email_binding_required and not is_admin(user.role)
|
||||
)
|
||||
user.email_verified_at = None
|
||||
if "email_verified" in updates:
|
||||
if not user.email:
|
||||
raise HTTPException(status_code=400, detail="未绑定邮箱时无法设置验证状态")
|
||||
_apply_email_verified(user, updates["email_verified"])
|
||||
|
||||
if settings.email_binding_required and not is_admin(user.role) and not user.email:
|
||||
raise HTTPException(status_code=400, detail="系统已开启「登录必须绑定邮箱」,该用户需绑定邮箱")
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return await _build_user_response(db, user, with_count=True)
|
||||
|
||||
|
||||
@users_router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current: User = Depends(require_user_manager),
|
||||
):
|
||||
if user_id == current.id:
|
||||
raise HTTPException(status_code=400, detail="不能删除当前登录账号")
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
return {"message": "用户已删除"}
|
||||
@@ -0,0 +1,108 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from .roles import ALL_ROLES
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=50)
|
||||
email: EmailStr
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
display_name: Optional[str] = None
|
||||
|
||||
|
||||
class VerifyEmailRequest(BaseModel):
|
||||
token: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class ResendVerificationRequest(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
token: str = Field(min_length=8, max_length=128)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class ForgotPasswordResponse(BaseModel):
|
||||
message: str
|
||||
email: str
|
||||
reset_sent: bool
|
||||
dev_reset_url: Optional[str] = None
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
message: str
|
||||
email: str
|
||||
verification_sent: bool
|
||||
verification_required: bool = True
|
||||
dev_verify_url: Optional[str] = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
display_name: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
email_verified: bool = False
|
||||
max_accounts: int = 3
|
||||
account_count: Optional[int] = None
|
||||
active_account_count: Optional[int] = None
|
||||
disabled_account_count: Optional[int] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=2, max_length=50)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
display_name: Optional[str] = None
|
||||
role: str = "operator"
|
||||
email: Optional[EmailStr] = None
|
||||
email_verified: bool = True
|
||||
max_accounts: int = Field(default=3, ge=0, le=999)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = Field(default=None, min_length=6, max_length=128)
|
||||
email: Optional[EmailStr] = None
|
||||
email_verified: Optional[bool] = None
|
||||
max_accounts: Optional[int] = Field(default=None, ge=0, le=999)
|
||||
|
||||
|
||||
class RoleInfo(BaseModel):
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class RolesResponse(BaseModel):
|
||||
roles: list[RoleInfo]
|
||||
@@ -0,0 +1,109 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import Account, AutoReplyRule, MessageLog, ReceivedMessageLog, SystemLog, User
|
||||
from .roles import is_admin
|
||||
|
||||
|
||||
async def get_owned_account(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
account_id: int,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> Account:
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||
if is_admin(user.role):
|
||||
return account
|
||||
if account.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该账号")
|
||||
if write and user.role == "viewer":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
||||
return account
|
||||
|
||||
|
||||
def accounts_for_user(user: User):
|
||||
stmt = select(Account)
|
||||
if not is_admin(user.role):
|
||||
stmt = stmt.where(Account.owner_id == user.id)
|
||||
return stmt
|
||||
|
||||
|
||||
async def owned_account_ids(db: AsyncSession, user: User) -> Optional[set[int]]:
|
||||
if is_admin(user.role):
|
||||
return None
|
||||
result = await db.execute(select(Account.id).where(Account.owner_id == user.id))
|
||||
return {row[0] for row in result.all()}
|
||||
|
||||
|
||||
def logs_for_user(user: User, account_id: Optional[int] = None):
|
||||
stmt = select(MessageLog)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(MessageLog.account_id == account_id)
|
||||
if not is_admin(user.role):
|
||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||
stmt = stmt.where(MessageLog.account_id.in_(owned))
|
||||
return stmt
|
||||
|
||||
|
||||
def received_logs_for_user(user: User, account_id: Optional[int] = None):
|
||||
stmt = select(ReceivedMessageLog)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(ReceivedMessageLog.account_id == account_id)
|
||||
if not is_admin(user.role):
|
||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||
stmt = stmt.where(ReceivedMessageLog.account_id.in_(owned))
|
||||
return stmt
|
||||
|
||||
|
||||
def rules_for_user(user: User, account_id: Optional[int] = None):
|
||||
stmt = select(AutoReplyRule)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(AutoReplyRule.account_id == account_id)
|
||||
if is_admin(user.role):
|
||||
return stmt
|
||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||
return stmt.where(AutoReplyRule.account_id.in_(owned))
|
||||
|
||||
|
||||
async def get_accessible_rule(db: AsyncSession, user: User, rule_id: int, *, write: bool = False) -> AutoReplyRule:
|
||||
result = await db.execute(select(AutoReplyRule).where(AutoReplyRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="规则不存在")
|
||||
|
||||
if is_admin(user.role):
|
||||
if write and user.role == "viewer":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
||||
return rule
|
||||
|
||||
if rule.account_id is None:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问全局规则")
|
||||
|
||||
account = await get_owned_account(db, user, rule.account_id, write=write)
|
||||
if rule.owner_id and rule.owner_id != user.id and account.owner_id != user.id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问该规则")
|
||||
if write and user.role == "viewer":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只读用户无法修改")
|
||||
return rule
|
||||
|
||||
|
||||
def system_logs_for_user(user: User, account_id: Optional[int] = None):
|
||||
stmt = select(SystemLog)
|
||||
if account_id is not None:
|
||||
stmt = stmt.where(SystemLog.account_id == account_id)
|
||||
if not is_admin(user.role):
|
||||
owned = select(Account.id).where(Account.owner_id == user.id)
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
SystemLog.account_id.in_(owned),
|
||||
SystemLog.account_id.is_(None),
|
||||
)
|
||||
)
|
||||
return stmt
|
||||
@@ -0,0 +1,346 @@
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.database import get_db
|
||||
from models.db_config import (
|
||||
PASSWORD_PLACEHOLDER as DB_PASSWORD_PLACEHOLDER,
|
||||
database_config_to_response,
|
||||
persist_database_config,
|
||||
test_database_connection,
|
||||
)
|
||||
from models.db_transfer import inspect_sqlite_source, migrate_sqlite_to_target
|
||||
from models.models import User
|
||||
from .dependencies import require_admin
|
||||
from .email_service import send_test_email
|
||||
from .system_settings import (
|
||||
PASSWORD_PLACEHOLDER,
|
||||
SystemSettingsData,
|
||||
load_settings,
|
||||
save_settings,
|
||||
settings_to_admin_response,
|
||||
settings_to_payment_response,
|
||||
settings_to_public,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
class PublicSettingsResponse(BaseModel):
|
||||
registration_enabled: bool
|
||||
email_verification_required: bool
|
||||
email_binding_required: bool
|
||||
|
||||
|
||||
class SystemSettingsResponse(BaseModel):
|
||||
registration_enabled: bool
|
||||
email_verification_required: bool
|
||||
email_binding_required: bool = False
|
||||
email_verify_token_hours: int
|
||||
auto_reply_delay_seconds: int = 0
|
||||
auto_reply_cooldown_seconds: int = 60
|
||||
app_url: str
|
||||
default_register_max_accounts: int = 3
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
smtp_user: str
|
||||
smtp_password: str = ""
|
||||
smtp_password_configured: bool = False
|
||||
smtp_from: str
|
||||
smtp_use_tls: bool
|
||||
smtp_use_ssl: bool = False
|
||||
debug_show_verify_link: bool
|
||||
email_verify_subject: str
|
||||
email_verify_body: str
|
||||
email_verify_html: str
|
||||
email_test_subject: str
|
||||
email_test_body: str
|
||||
email_test_html: str
|
||||
|
||||
|
||||
class SystemSettingsUpdate(BaseModel):
|
||||
registration_enabled: bool | None = None
|
||||
email_verification_required: bool | None = None
|
||||
email_binding_required: bool | None = None
|
||||
email_verify_token_hours: int | None = Field(default=None, ge=1, le=168)
|
||||
auto_reply_delay_seconds: int | None = Field(default=None, ge=0, le=86400)
|
||||
auto_reply_cooldown_seconds: int | None = Field(default=None, ge=0, le=86400)
|
||||
app_url: str | None = None
|
||||
default_register_max_accounts: int | None = Field(default=None, ge=0, le=999)
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
smtp_use_tls: bool | None = None
|
||||
smtp_use_ssl: bool | None = None
|
||||
debug_show_verify_link: bool | None = None
|
||||
email_verify_subject: str | None = None
|
||||
email_verify_body: str | None = None
|
||||
email_verify_html: str | None = None
|
||||
email_test_subject: str | None = None
|
||||
email_test_body: str | None = None
|
||||
email_test_html: str | None = None
|
||||
|
||||
|
||||
class TestEmailRequest(BaseModel):
|
||||
to_email: EmailStr
|
||||
smtp_host: str | None = None
|
||||
smtp_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
smtp_user: str | None = None
|
||||
smtp_password: str | None = None
|
||||
smtp_from: str | None = None
|
||||
smtp_use_tls: bool | None = None
|
||||
smtp_use_ssl: bool | None = None
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class DatabaseSettingsResponse(BaseModel):
|
||||
db_type: str
|
||||
db_host: str = ""
|
||||
db_port: int = 0
|
||||
db_user: str = ""
|
||||
db_password: str = ""
|
||||
db_password_configured: bool = False
|
||||
db_name: str = ""
|
||||
db_path: str = ""
|
||||
database_url_display: str = ""
|
||||
supported_types: list[str] = []
|
||||
|
||||
|
||||
class DatabaseSettingsUpdate(BaseModel):
|
||||
db_type: str
|
||||
db_host: str | None = None
|
||||
db_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
db_user: str | None = None
|
||||
db_password: str | None = None
|
||||
db_name: str | None = None
|
||||
db_path: str | None = None
|
||||
|
||||
|
||||
class DatabaseTestRequest(BaseModel):
|
||||
db_type: str
|
||||
db_host: str | None = None
|
||||
db_port: int | None = Field(default=None, ge=1, le=65535)
|
||||
db_user: str | None = None
|
||||
db_password: str | None = None
|
||||
db_name: str | None = None
|
||||
db_path: str | None = None
|
||||
|
||||
|
||||
class DatabaseMigratePreviewResponse(BaseModel):
|
||||
source_path: str
|
||||
exists: bool
|
||||
tables: dict[str, int]
|
||||
total_rows: int
|
||||
|
||||
|
||||
class DatabaseMigrateRequest(DatabaseTestRequest):
|
||||
source_db_path: str | None = None
|
||||
clear_target: bool = False
|
||||
|
||||
|
||||
class DatabaseMigrateResponse(BaseModel):
|
||||
message: str
|
||||
source_path: str
|
||||
target_type: str
|
||||
target_url: str
|
||||
tables: dict[str, int]
|
||||
total_rows: int
|
||||
|
||||
|
||||
class PaymentSettingsResponse(BaseModel):
|
||||
app_url: str
|
||||
payment_enabled: bool = False
|
||||
payment_demo_mode: bool = True
|
||||
wechat_pay_enabled: bool = False
|
||||
alipay_pay_enabled: bool = False
|
||||
account_slot_unit_price: float = 9.9
|
||||
account_slot_purchase_min: int = 1
|
||||
account_slot_purchase_max: int = 20
|
||||
wechat_app_id: str = ""
|
||||
wechat_mch_id: str = ""
|
||||
wechat_api_v3_key: str = ""
|
||||
wechat_api_v3_key_configured: bool = False
|
||||
wechat_cert_serial: str = ""
|
||||
wechat_private_key: str = ""
|
||||
wechat_private_key_configured: bool = False
|
||||
wechat_pay_configured: bool = False
|
||||
alipay_app_id: str = ""
|
||||
alipay_private_key: str = ""
|
||||
alipay_private_key_configured: bool = False
|
||||
alipay_public_key: str = ""
|
||||
alipay_sandbox: bool = False
|
||||
alipay_configured: bool = False
|
||||
|
||||
|
||||
class PaymentSettingsUpdate(BaseModel):
|
||||
payment_enabled: bool | None = None
|
||||
payment_demo_mode: bool | None = None
|
||||
wechat_pay_enabled: bool | None = None
|
||||
alipay_pay_enabled: bool | None = None
|
||||
account_slot_unit_price: float | None = Field(default=None, ge=0.01, le=99999)
|
||||
account_slot_purchase_min: int | None = Field(default=None, ge=1, le=100)
|
||||
account_slot_purchase_max: int | None = Field(default=None, ge=1, le=100)
|
||||
wechat_app_id: str | None = None
|
||||
wechat_mch_id: str | None = None
|
||||
wechat_api_v3_key: str | None = None
|
||||
wechat_cert_serial: str | None = None
|
||||
wechat_private_key: str | None = None
|
||||
alipay_app_id: str | None = None
|
||||
alipay_private_key: str | None = None
|
||||
alipay_public_key: str | None = None
|
||||
alipay_sandbox: bool | None = None
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicSettingsResponse)
|
||||
async def get_public_settings(db: AsyncSession = Depends(get_db)):
|
||||
data = await load_settings(db)
|
||||
return PublicSettingsResponse(**settings_to_public(data))
|
||||
|
||||
|
||||
@router.get("", response_model=SystemSettingsResponse)
|
||||
async def get_system_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
data = await load_settings(db)
|
||||
return SystemSettingsResponse(**settings_to_admin_response(data))
|
||||
|
||||
|
||||
@router.put("", response_model=SystemSettingsResponse)
|
||||
async def update_system_settings(
|
||||
body: SystemSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
if "app_url" in updates and updates["app_url"]:
|
||||
updates["app_url"] = updates["app_url"].strip().rstrip("/")
|
||||
data = await save_settings(db, updates)
|
||||
return SystemSettingsResponse(**settings_to_admin_response(data))
|
||||
|
||||
|
||||
@router.get("/payment", response_model=PaymentSettingsResponse)
|
||||
async def get_payment_settings(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
data = await load_settings(db)
|
||||
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
||||
|
||||
|
||||
@router.put("/payment", response_model=PaymentSettingsResponse)
|
||||
async def update_payment_settings(
|
||||
body: PaymentSettingsUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
updates = body.model_dump(exclude_unset=True)
|
||||
data = await save_settings(db, updates)
|
||||
return PaymentSettingsResponse(**settings_to_payment_response(data))
|
||||
|
||||
|
||||
@router.post("/test-email", response_model=MessageResponse)
|
||||
async def test_smtp_email(
|
||||
body: TestEmailRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
data = await load_settings(db)
|
||||
overrides = body.model_dump(exclude_unset=True, exclude={"to_email"})
|
||||
if overrides:
|
||||
merged = asdict(data)
|
||||
for key, value in overrides.items():
|
||||
if value is None:
|
||||
continue
|
||||
if key == "smtp_password":
|
||||
pwd = str(value).strip()
|
||||
if not pwd or pwd == PASSWORD_PLACEHOLDER:
|
||||
continue
|
||||
merged[key] = value
|
||||
data = SystemSettingsData(**merged)
|
||||
if not data.smtp_configured():
|
||||
raise HTTPException(status_code=400, detail="请先完整配置 SMTP 服务器与发件人")
|
||||
try:
|
||||
await send_test_email(str(body.to_email), data)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return MessageResponse(message=f"测试邮件已发送至 {body.to_email}")
|
||||
|
||||
|
||||
@router.get("/database", response_model=DatabaseSettingsResponse)
|
||||
async def get_database_settings(_: User = Depends(require_admin)):
|
||||
return DatabaseSettingsResponse(**database_config_to_response())
|
||||
|
||||
|
||||
@router.put("/database", response_model=MessageResponse)
|
||||
async def update_database_settings(
|
||||
body: DatabaseSettingsUpdate,
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||
payload.pop("db_password", None)
|
||||
try:
|
||||
await test_database_connection(payload)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"连接测试失败: {exc}") from exc
|
||||
try:
|
||||
persist_database_config(payload)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"写入配置失败: {exc}") from exc
|
||||
return MessageResponse(message="数据库配置已保存至 .env,请重启后端服务后生效")
|
||||
|
||||
|
||||
@router.post("/database/test", response_model=MessageResponse)
|
||||
async def test_database_settings(
|
||||
body: DatabaseTestRequest,
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||
payload.pop("db_password", None)
|
||||
try:
|
||||
await test_database_connection(payload)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return MessageResponse(message="数据库连接测试成功")
|
||||
|
||||
|
||||
@router.get("/database/migrate/preview", response_model=DatabaseMigratePreviewResponse)
|
||||
async def preview_database_migration(
|
||||
source_db_path: str | None = None,
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
return DatabaseMigratePreviewResponse(**await inspect_sqlite_source(source_db_path))
|
||||
|
||||
|
||||
@router.post("/database/migrate", response_model=DatabaseMigrateResponse)
|
||||
async def migrate_database_data(
|
||||
body: DatabaseMigrateRequest,
|
||||
_: User = Depends(require_admin),
|
||||
):
|
||||
payload = body.model_dump(exclude_unset=True)
|
||||
clear_target = bool(payload.pop("clear_target", False))
|
||||
if payload.get("db_password") in (None, "", DB_PASSWORD_PLACEHOLDER):
|
||||
payload.pop("db_password", None)
|
||||
try:
|
||||
await test_database_connection(payload)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"目标库连接失败: {exc}") from exc
|
||||
try:
|
||||
result = await migrate_sqlite_to_target(payload, clear_target=clear_target)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"迁移失败: {exc}") from exc
|
||||
return DatabaseMigrateResponse(**result)
|
||||
@@ -0,0 +1,366 @@
|
||||
"""系统功能配置(数据库存储 + 内存缓存)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.models import AppConfig
|
||||
|
||||
CONFIG_ROW_ID = 1
|
||||
PASSWORD_PLACEHOLDER = "******"
|
||||
APP_NAME = "抖音回复助手"
|
||||
|
||||
DEFAULT_EMAIL_VERIFY_SUBJECT = f"【{APP_NAME}】请验证您的邮箱"
|
||||
DEFAULT_EMAIL_VERIFY_BODY = (
|
||||
"您好 {username},\n\n"
|
||||
"感谢注册{app_name}。请点击以下链接完成邮箱验证:\n"
|
||||
"{link}\n\n"
|
||||
"如非本人操作,请忽略此邮件。\n"
|
||||
)
|
||||
DEFAULT_EMAIL_VERIFY_HTML = (
|
||||
"<p>您好 <strong>{username}</strong>,</p>"
|
||||
"<p>感谢注册{app_name}。请点击下方按钮完成邮箱验证:</p>"
|
||||
'<p><a href="{link}" style="display:inline-block;padding:10px 18px;'
|
||||
'background:#aa3bff;color:#fff;text-decoration:none;border-radius:6px;">'
|
||||
"验证邮箱</a></p>"
|
||||
'<p>或复制链接到浏览器:<br><a href="{link}">{link}</a></p>'
|
||||
'<p style="color:#888;font-size:12px;">如非本人操作,请忽略此邮件。</p>'
|
||||
)
|
||||
DEFAULT_EMAIL_TEST_SUBJECT = f"【{APP_NAME}】SMTP 测试邮件"
|
||||
DEFAULT_EMAIL_TEST_BODY = "这是一封 SMTP 配置测试邮件。若您收到此邮件,说明邮件服务已配置正确。"
|
||||
DEFAULT_EMAIL_TEST_HTML = (
|
||||
"<p>这是一封 <strong>SMTP 配置测试</strong> 邮件。</p>"
|
||||
"<p>若您收到此邮件,说明邮件服务已配置正确。</p>"
|
||||
)
|
||||
DEFAULT_PASSWORD_RESET_SUBJECT = f"【{APP_NAME}】重置您的登录密码"
|
||||
DEFAULT_PASSWORD_RESET_BODY = (
|
||||
"您好 {username},\n\n"
|
||||
"我们收到了重置 {app_name} 账号密码的请求。请点击以下链接设置新密码:\n"
|
||||
"{link}\n\n"
|
||||
"链接有效期 {expire_hours} 小时。如非本人操作,请忽略此邮件。\n"
|
||||
)
|
||||
DEFAULT_PASSWORD_RESET_HTML = (
|
||||
"<p>您好 <strong>{username}</strong>,</p>"
|
||||
"<p>我们收到了重置 {app_name} 账号密码的请求。请点击下方按钮设置新密码:</p>"
|
||||
'<p><a href="{link}" style="display:inline-block;padding:10px 18px;'
|
||||
'background:#aa3bff;color:#fff;text-decoration:none;border-radius:6px;">'
|
||||
"重置密码</a></p>"
|
||||
'<p>或复制链接到浏览器:<br><a href="{link}">{link}</a></p>'
|
||||
'<p style="color:#888;font-size:12px;">链接有效期 {expire_hours} 小时。如非本人操作,请忽略此邮件。</p>'
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SystemSettingsData:
|
||||
registration_enabled: bool = True
|
||||
email_verification_required: bool = True
|
||||
email_binding_required: bool = False
|
||||
email_verify_token_hours: int = 24
|
||||
auto_reply_delay_seconds: int = 0
|
||||
auto_reply_cooldown_seconds: int = 60
|
||||
app_url: str = "http://localhost:8800"
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 587
|
||||
smtp_user: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from: str = ""
|
||||
smtp_use_tls: bool = True
|
||||
smtp_use_ssl: bool = False
|
||||
debug_show_verify_link: bool = False
|
||||
default_register_max_accounts: int = 3
|
||||
payment_enabled: bool = False
|
||||
payment_demo_mode: bool = True
|
||||
wechat_pay_enabled: bool = False
|
||||
alipay_pay_enabled: bool = False
|
||||
account_slot_unit_price: float = 9.9
|
||||
account_slot_purchase_min: int = 1
|
||||
account_slot_purchase_max: int = 20
|
||||
wechat_app_id: str = ""
|
||||
wechat_mch_id: str = ""
|
||||
wechat_api_v3_key: str = ""
|
||||
wechat_cert_serial: str = ""
|
||||
wechat_private_key: str = ""
|
||||
alipay_app_id: str = ""
|
||||
alipay_private_key: str = ""
|
||||
alipay_public_key: str = ""
|
||||
alipay_sandbox: bool = False
|
||||
email_verify_subject: str = DEFAULT_EMAIL_VERIFY_SUBJECT
|
||||
email_verify_body: str = DEFAULT_EMAIL_VERIFY_BODY
|
||||
email_verify_html: str = DEFAULT_EMAIL_VERIFY_HTML
|
||||
email_test_subject: str = DEFAULT_EMAIL_TEST_SUBJECT
|
||||
email_test_body: str = DEFAULT_EMAIL_TEST_BODY
|
||||
email_test_html: str = DEFAULT_EMAIL_TEST_HTML
|
||||
|
||||
def smtp_configured(self) -> bool:
|
||||
sender = (self.smtp_from or self.smtp_user or "").strip()
|
||||
return bool(self.smtp_host.strip() and sender)
|
||||
|
||||
def app_url_normalized(self) -> str:
|
||||
return (self.app_url or "http://localhost:8800").rstrip("/")
|
||||
|
||||
def wechat_pay_configured(self) -> bool:
|
||||
return bool(
|
||||
self.wechat_app_id.strip()
|
||||
and self.wechat_mch_id.strip()
|
||||
and self.wechat_api_v3_key.strip()
|
||||
and self.wechat_cert_serial.strip()
|
||||
and self.wechat_private_key.strip()
|
||||
)
|
||||
|
||||
def alipay_configured(self) -> bool:
|
||||
return bool(
|
||||
self.alipay_app_id.strip()
|
||||
and self.alipay_private_key.strip()
|
||||
and self.alipay_public_key.strip()
|
||||
)
|
||||
|
||||
def payment_channel_available(self, channel: str) -> bool:
|
||||
if channel == "wechat":
|
||||
return self.wechat_pay_enabled and self.wechat_pay_configured()
|
||||
if channel == "alipay":
|
||||
return self.alipay_pay_enabled and self.alipay_configured()
|
||||
return False
|
||||
|
||||
def payment_channel_selectable(self, channel: str) -> bool:
|
||||
"""用户可选的支付渠道(含演示模式)。"""
|
||||
if channel == "wechat":
|
||||
if not self.wechat_pay_enabled:
|
||||
return False
|
||||
return self.wechat_pay_configured() or (
|
||||
self.payment_demo_mode and self.payment_enabled
|
||||
)
|
||||
if channel == "alipay":
|
||||
if not self.alipay_pay_enabled:
|
||||
return False
|
||||
return self.alipay_configured() or (
|
||||
self.payment_demo_mode and self.payment_enabled
|
||||
)
|
||||
return False
|
||||
|
||||
def available_payment_channels(self) -> list[str]:
|
||||
channels = []
|
||||
if self.payment_channel_selectable("wechat"):
|
||||
channels.append("wechat")
|
||||
if self.payment_channel_selectable("alipay"):
|
||||
channels.append("alipay")
|
||||
return channels
|
||||
|
||||
|
||||
_settings_cache: SystemSettingsData | None = None
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("1", "true", "yes", "on")
|
||||
if value is None:
|
||||
return default
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _parse_settings(raw: dict[str, Any]) -> SystemSettingsData:
|
||||
base = SystemSettingsData()
|
||||
allowed = {f.name for f in fields(SystemSettingsData)}
|
||||
merged: dict[str, Any] = {}
|
||||
for key in allowed:
|
||||
if key in raw:
|
||||
merged[key] = raw[key]
|
||||
if "smtp_port" in merged:
|
||||
try:
|
||||
merged["smtp_port"] = int(merged["smtp_port"])
|
||||
except (TypeError, ValueError):
|
||||
merged["smtp_port"] = base.smtp_port
|
||||
if "email_verify_token_hours" in merged:
|
||||
try:
|
||||
merged["email_verify_token_hours"] = max(
|
||||
1, min(168, int(merged["email_verify_token_hours"]))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
merged["email_verify_token_hours"] = base.email_verify_token_hours
|
||||
if "auto_reply_cooldown_seconds" in merged:
|
||||
try:
|
||||
merged["auto_reply_cooldown_seconds"] = max(
|
||||
0, min(86400, int(merged["auto_reply_cooldown_seconds"]))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
merged["auto_reply_cooldown_seconds"] = base.auto_reply_cooldown_seconds
|
||||
if "auto_reply_delay_seconds" in merged:
|
||||
try:
|
||||
merged["auto_reply_delay_seconds"] = max(
|
||||
0, min(86400, int(merged["auto_reply_delay_seconds"]))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
merged["auto_reply_delay_seconds"] = base.auto_reply_delay_seconds
|
||||
if "default_register_max_accounts" in merged:
|
||||
try:
|
||||
merged["default_register_max_accounts"] = max(
|
||||
0, min(999, int(merged["default_register_max_accounts"]))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
merged["default_register_max_accounts"] = base.default_register_max_accounts
|
||||
if "account_slot_unit_price" in merged:
|
||||
try:
|
||||
merged["account_slot_unit_price"] = max(
|
||||
0.01, min(99999.0, float(merged["account_slot_unit_price"]))
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
merged["account_slot_unit_price"] = base.account_slot_unit_price
|
||||
for int_key, lo, hi in (
|
||||
("account_slot_purchase_min", 1, 100),
|
||||
("account_slot_purchase_max", 1, 100),
|
||||
):
|
||||
if int_key in merged:
|
||||
try:
|
||||
merged[int_key] = max(lo, min(hi, int(merged[int_key])))
|
||||
except (TypeError, ValueError):
|
||||
merged[int_key] = getattr(base, int_key)
|
||||
for bool_key in (
|
||||
"registration_enabled",
|
||||
"email_verification_required",
|
||||
"email_binding_required",
|
||||
"smtp_use_tls",
|
||||
"smtp_use_ssl",
|
||||
"debug_show_verify_link",
|
||||
"payment_enabled",
|
||||
"payment_demo_mode",
|
||||
"wechat_pay_enabled",
|
||||
"alipay_pay_enabled",
|
||||
"alipay_sandbox",
|
||||
):
|
||||
if bool_key in merged:
|
||||
merged[bool_key] = _coerce_bool(merged[bool_key], getattr(base, bool_key))
|
||||
return SystemSettingsData(**{**asdict(base), **merged})
|
||||
|
||||
|
||||
def get_cached_settings() -> SystemSettingsData:
|
||||
global _settings_cache
|
||||
if _settings_cache is None:
|
||||
_settings_cache = SystemSettingsData()
|
||||
return _settings_cache
|
||||
|
||||
|
||||
def set_cached_settings(data: SystemSettingsData) -> None:
|
||||
global _settings_cache
|
||||
_settings_cache = data
|
||||
|
||||
|
||||
async def load_settings(db: AsyncSession) -> SystemSettingsData:
|
||||
result = await db.execute(select(AppConfig).where(AppConfig.id == CONFIG_ROW_ID))
|
||||
row = result.scalar_one_or_none()
|
||||
if not row or not row.data:
|
||||
data = SystemSettingsData()
|
||||
set_cached_settings(data)
|
||||
return data
|
||||
try:
|
||||
payload = json.loads(row.data)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
data = _parse_settings(payload if isinstance(payload, dict) else {})
|
||||
set_cached_settings(data)
|
||||
return data
|
||||
|
||||
|
||||
async def ensure_default_settings(db: AsyncSession) -> SystemSettingsData:
|
||||
result = await db.execute(select(AppConfig).where(AppConfig.id == CONFIG_ROW_ID))
|
||||
row = result.scalar_one_or_none()
|
||||
if row:
|
||||
return await load_settings(db)
|
||||
data = SystemSettingsData()
|
||||
row = AppConfig(
|
||||
id=CONFIG_ROW_ID,
|
||||
data=json.dumps(asdict(data), ensure_ascii=False),
|
||||
updated_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(row)
|
||||
await db.commit()
|
||||
set_cached_settings(data)
|
||||
return data
|
||||
|
||||
|
||||
async def save_settings(db: AsyncSession, updates: dict[str, Any]) -> SystemSettingsData:
|
||||
current = await load_settings(db)
|
||||
merged = asdict(current)
|
||||
for key, value in updates.items():
|
||||
if key not in merged or value is None:
|
||||
continue
|
||||
if key == "smtp_password":
|
||||
pwd = str(value).strip()
|
||||
if not pwd or pwd == PASSWORD_PLACEHOLDER:
|
||||
continue
|
||||
merged[key] = pwd
|
||||
continue
|
||||
if key in ("wechat_api_v3_key", "wechat_private_key", "alipay_private_key"):
|
||||
secret = str(value).strip()
|
||||
if not secret or secret == PASSWORD_PLACEHOLDER:
|
||||
continue
|
||||
merged[key] = secret
|
||||
continue
|
||||
merged[key] = value
|
||||
data = _parse_settings(merged)
|
||||
|
||||
result = await db.execute(select(AppConfig).where(AppConfig.id == CONFIG_ROW_ID))
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
row = AppConfig(id=CONFIG_ROW_ID, data="{}", updated_at=datetime.utcnow())
|
||||
db.add(row)
|
||||
row.data = json.dumps(asdict(data), ensure_ascii=False)
|
||||
row.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
set_cached_settings(data)
|
||||
return data
|
||||
|
||||
|
||||
def settings_to_public(data: SystemSettingsData) -> dict[str, bool]:
|
||||
return {
|
||||
"registration_enabled": data.registration_enabled,
|
||||
"email_verification_required": data.email_verification_required,
|
||||
"email_binding_required": data.email_binding_required,
|
||||
}
|
||||
|
||||
|
||||
def settings_to_admin_response(data: SystemSettingsData) -> dict[str, Any]:
|
||||
payload = asdict(data)
|
||||
payload["smtp_password"] = PASSWORD_PLACEHOLDER if data.smtp_password else ""
|
||||
payload["smtp_password_configured"] = bool(data.smtp_password)
|
||||
return payload
|
||||
|
||||
|
||||
PAYMENT_SETTING_KEYS = (
|
||||
"payment_enabled",
|
||||
"payment_demo_mode",
|
||||
"wechat_pay_enabled",
|
||||
"alipay_pay_enabled",
|
||||
"account_slot_unit_price",
|
||||
"account_slot_purchase_min",
|
||||
"account_slot_purchase_max",
|
||||
"wechat_app_id",
|
||||
"wechat_mch_id",
|
||||
"wechat_api_v3_key",
|
||||
"wechat_cert_serial",
|
||||
"wechat_private_key",
|
||||
"alipay_app_id",
|
||||
"alipay_private_key",
|
||||
"alipay_public_key",
|
||||
"alipay_sandbox",
|
||||
)
|
||||
|
||||
|
||||
def settings_to_payment_response(data: SystemSettingsData) -> dict[str, Any]:
|
||||
payload = {key: getattr(data, key) for key in PAYMENT_SETTING_KEYS}
|
||||
payload["app_url"] = data.app_url
|
||||
payload["wechat_api_v3_key"] = PASSWORD_PLACEHOLDER if data.wechat_api_v3_key else ""
|
||||
payload["wechat_api_v3_key_configured"] = bool(data.wechat_api_v3_key)
|
||||
payload["wechat_private_key"] = PASSWORD_PLACEHOLDER if data.wechat_private_key else ""
|
||||
payload["wechat_private_key_configured"] = bool(data.wechat_private_key)
|
||||
payload["alipay_private_key"] = PASSWORD_PLACEHOLDER if data.alipay_private_key else ""
|
||||
payload["alipay_private_key_configured"] = bool(data.alipay_private_key)
|
||||
payload["wechat_pay_configured"] = data.wechat_pay_configured()
|
||||
payload["alipay_configured"] = data.alipay_configured()
|
||||
return payload
|
||||
Reference in New Issue
Block a user