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_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_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)