更新
This commit is contained in:
@@ -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