This commit is contained in:
Your Name
2026-07-23 17:56:25 +08:00
parent a05dae8412
commit 4970d8f8d3
4262 changed files with 735221 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+57
View File
@@ -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
+68
View File
@@ -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)
+50
View File
@@ -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
+378
View File
@@ -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)
+103
View File
@@ -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
+78
View File
@@ -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}"
+24
View File
@@ -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
+64
View File
@@ -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
+11
View File
@@ -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)
+31
View File
@@ -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
+511
View File
@@ -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": "用户已删除"}
+108
View File
@@ -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]
+109
View File
@@ -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
+346
View File
@@ -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)
+366
View File
@@ -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