from copy import deepcopy from datetime import UTC, datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, EmailStr, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import get_current_user from app.core.time import to_iso8601_utc from app.core.config import settings as app_settings from app.core.data_sources import get_data_sources_config from app.core.datasource_defaults import DEFAULT_DATASOURCES from app.db.session import get_db from app.models.datasource import DataSource from app.models.datasource_config import DataSourceConfig from app.models.system_setting import SystemSetting from app.models.user import User from app.services.barentswatch import ( BarentsWatchConfig, check_barentswatch_config, check_barentswatch_connectivity, get_barentswatch_datasource_record, resolve_barentswatch_config, ) from app.services.credential_guides import ( generate_credential_guide, get_credential_guide, reset_credential_guide, ) from app.services.datasource_connectivity import ( build_builtin_connectivity_checksum, save_connectivity_success, ) from app.services.ai_client import AIProviderClient, get_ai_provider_client from app.services.llm_provider_catalog import ( get_fallback_llm_provider_preset, list_fallback_llm_provider_presets, refresh_llm_provider_preset, ) from app.services.scheduler import sync_datasource_job from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings router = APIRouter() DEFAULT_SETTINGS = { "system": { "system_name": "智能星球", "refresh_interval": 60, "auto_refresh": True, "data_retention_days": 30, "max_concurrent_tasks": 5, }, "notifications": { "email_enabled": False, "email_address": "", "critical_alerts": True, "warning_alerts": True, "daily_summary": False, }, "security": { "session_timeout": 60, "max_login_attempts": 5, "password_policy": "medium", }, "tv": DEFAULT_TV_SETTINGS, "external_integrations": { "ai_provider": { "service_url": "", "service_token": "", "provider": "minimax", "provider_api": "anthropic-messages", "base_url": "https://api.minimaxi.com/anthropic", "model": "MiniMax-M2.7", "api_key": "", "max_tokens": 1200, "anthropic_version": "2023-06-01", "timeout_seconds": 60, "retry_attempts": 2, } }, } class SystemSettingsUpdate(BaseModel): system_name: str = "智能星球" refresh_interval: int = Field(default=60, ge=10, le=3600) auto_refresh: bool = True data_retention_days: int = Field(default=30, ge=1, le=3650) max_concurrent_tasks: int = Field(default=5, ge=1, le=50) class NotificationSettingsUpdate(BaseModel): email_enabled: bool = False email_address: Optional[EmailStr] = None critical_alerts: bool = True warning_alerts: bool = True daily_summary: bool = False class SecuritySettingsUpdate(BaseModel): session_timeout: int = Field(default=60, ge=5, le=1440) max_login_attempts: int = Field(default=5, ge=1, le=20) password_policy: str = Field(default="medium") class CollectorSettingsUpdate(BaseModel): is_active: bool priority: str = Field(default="P1") frequency_minutes: int = Field(default=60, ge=1, le=10080) class TVStreamSourceUpdate(BaseModel): id: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=200) provider: str = Field(default="Unknown", max_length=100) region: str = Field(default="Global", max_length=100) language: str = Field(default="und", max_length=32) source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$") embed_url: str = "" stream_url: str = "" homepage_url: str = "" poster_url: str = "" youtube_video_id: str = "" youtube_channel: str = "" is_enabled: bool = True is_fallback: bool = False sort_order: int = Field(default=10, ge=0, le=9999) collector_source: Optional[str] = None notes: str = "" class TVSettingsUpdate(BaseModel): default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1) auto_fallback: bool = True sources: list[TVStreamSourceUpdate] = Field(default_factory=list) class AIProviderIntegrationUpdate(BaseModel): service_url: str = "" service_token: Optional[str] = None provider: str = Field(default="minimax", max_length=80) provider_api: str = Field(default="anthropic-messages", max_length=80) base_url: str = Field(default="", max_length=500) model: str = Field(default="", max_length=200) api_key: Optional[str] = None max_tokens: int = Field(default=1200, ge=1, le=200000) anthropic_version: str = Field(default="2023-06-01", max_length=40) timeout_seconds: int = Field(default=60, ge=5, le=600) retry_attempts: int = Field(default=2, ge=1, le=10) clear_service_token: bool = False clear_api_key: bool = False class BarentsWatchIntegrationUpdate(BaseModel): endpoint: str = "" client_id: str = "" client_secret: Optional[str] = None clear_client_secret: bool = False class ExternalIntegrationsUpdate(BaseModel): ai_provider: AIProviderIntegrationUpdate barentswatch: BarentsWatchIntegrationUpdate def merge_with_defaults(category: str, payload: Optional[dict]) -> dict: merged = deepcopy(DEFAULT_SETTINGS[category]) if payload: merged.update(payload) return merged async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]: result = await db.execute(select(SystemSetting).where(SystemSetting.category == category)) return result.scalar_one_or_none() async def get_setting_payloads(db: AsyncSession, categories: list[str]) -> dict[str, dict]: if not categories: return {} result = await db.execute( select(SystemSetting).where(SystemSetting.category.in_(categories)) ) records_by_category = { record.category: record for record in result.scalars().all() } return { category: merge_with_defaults( category, records_by_category.get(category).payload if records_by_category.get(category) else None, ) for category in categories } async def get_setting_payload(db: AsyncSession, category: str) -> dict: record = await get_setting_record(db, category) return merge_with_defaults(category, record.payload if record else None) async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict: record = await get_setting_record(db, category) if record is None: record = SystemSetting(category=category, payload=payload) db.add(record) else: record.payload = payload await db.commit() await db.refresh(record) return merge_with_defaults(category, record.payload) def _mask_secret(value: Optional[str]) -> dict: if not value: return {"configured": False, "preview": ""} text = str(value) if "-" in text: prefix = text.split("-", 1)[0] + "-" preview = prefix + ("*" * max(len(text) - len(prefix), 1)) else: prefix_len = min(4, len(text)) preview = text[:prefix_len] + ("*" * max(len(text) - prefix_len, 1)) return {"configured": True, "preview": preview} async def get_runtime_ai_provider_config(db: AsyncSession) -> dict: runtime_record = await get_setting_record(db, "external_integrations") payload = merge_with_defaults( "external_integrations", runtime_record.payload if runtime_record else None, ) ai_payload = payload.get("ai_provider") or {} has_runtime_llm_config = bool( runtime_record and isinstance(runtime_record.payload, dict) and isinstance(runtime_record.payload.get("ai_provider"), dict) ) return { "service_url": ai_payload.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL, "service_token": ai_payload.get("service_token") or app_settings.AI_PROVIDER_SERVICE_TOKEN, "timeout_seconds": int( ai_payload.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS ), "retry_attempts": int( ai_payload.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS ), "llm_config": { "provider": ai_payload.get("provider") or "minimax", "provider_api": ai_payload.get("provider_api") or "anthropic-messages", "base_url": ai_payload.get("base_url") or "https://api.minimaxi.com/anthropic", "model": ai_payload.get("model") or "MiniMax-M2.7", "api_key": ai_payload.get("api_key") or "", "max_tokens": int(ai_payload.get("max_tokens") or 1200), "anthropic_version": ai_payload.get("anthropic_version") or "2023-06-01", } if has_runtime_llm_config else {}, } async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]: return await get_barentswatch_datasource_record(db) async def serialize_external_integrations(db: AsyncSession) -> dict: ai_config = await get_runtime_ai_provider_config(db) runtime_setting = await get_setting_record(db, "external_integrations") display_llm_config = ai_config["llm_config"] or DEFAULT_SETTINGS["external_integrations"]["ai_provider"] barentswatch_record = await get_barentswatch_config_record(db) barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {} barentswatch_auth = barentswatch_auth or {} resolved_barentswatch = await resolve_barentswatch_config(db) return { "ai_provider": { "service_url": ai_config["service_url"], "service_token": _mask_secret(ai_config["service_token"]), "provider": display_llm_config.get("provider") or "minimax", "provider_api": display_llm_config.get("provider_api") or "anthropic-messages", "base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic", "model": display_llm_config.get("model") or "MiniMax-M2.7", "api_key": _mask_secret(display_llm_config.get("api_key")), "max_tokens": int(display_llm_config.get("max_tokens") or 1200), "anthropic_version": display_llm_config.get("anthropic_version") or "2023-06-01", "timeout_seconds": ai_config["timeout_seconds"], "retry_attempts": ai_config["retry_attempts"], "source": "runtime" if runtime_setting else "env", }, "barentswatch": { "endpoint": resolved_barentswatch.endpoint, "client_id": barentswatch_auth.get("client_id") or resolved_barentswatch.client_id, "client_secret": _mask_secret( barentswatch_auth.get("client_secret") or resolved_barentswatch.client_secret ), "source": resolved_barentswatch.credential_source, }, } async def save_external_integrations_payload( db: AsyncSession, update: ExternalIntegrationsUpdate, ) -> dict: current_payload = await get_setting_payload(db, "external_integrations") current_ai = current_payload.get("ai_provider") or {} ai_payload = { "service_url": update.ai_provider.service_url.strip() or app_settings.AI_PROVIDER_SERVICE_URL, "service_token": current_ai.get("service_token") or "", "provider": update.ai_provider.provider.strip() or "minimax", "provider_api": update.ai_provider.provider_api.strip() or "anthropic-messages", "base_url": update.ai_provider.base_url.strip(), "model": update.ai_provider.model.strip(), "api_key": current_ai.get("api_key") or "", "max_tokens": update.ai_provider.max_tokens, "anthropic_version": update.ai_provider.anthropic_version.strip() or "2023-06-01", "timeout_seconds": update.ai_provider.timeout_seconds, "retry_attempts": update.ai_provider.retry_attempts, } if update.ai_provider.clear_service_token: ai_payload["service_token"] = "" elif update.ai_provider.service_token not in (None, ""): ai_payload["service_token"] = update.ai_provider.service_token if update.ai_provider.clear_api_key: ai_payload["api_key"] = "" elif update.ai_provider.api_key not in (None, ""): ai_payload["api_key"] = update.ai_provider.api_key await save_setting_payload(db, "external_integrations", {"ai_provider": ai_payload}) default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels") barentswatch_record = await get_barentswatch_config_record(db) if barentswatch_record is None: barentswatch_record = DataSourceConfig( name="barentswatch_vessels", description="BarentsWatch Live AIS credentials", source_type="api", endpoint=update.barentswatch.endpoint.strip() or default_endpoint, auth_type="oauth_client", auth_config={}, headers={}, config={}, is_active=True, ) db.add(barentswatch_record) current_auth = dict(barentswatch_record.auth_config or {}) if update.barentswatch.clear_client_secret: current_auth.pop("client_secret", None) elif update.barentswatch.client_secret not in (None, ""): current_auth["client_secret"] = update.barentswatch.client_secret current_auth["client_id"] = update.barentswatch.client_id.strip() barentswatch_record.endpoint = update.barentswatch.endpoint.strip() or default_endpoint barentswatch_record.auth_type = "oauth_client" barentswatch_record.auth_config = current_auth await db.commit() return await serialize_external_integrations(db) def format_frequency_label(minutes: int) -> str: if minutes % 1440 == 0: return f"{minutes // 1440}d" if minutes % 60 == 0: return f"{minutes // 60}h" return f"{minutes}m" def serialize_collector(datasource: DataSource) -> dict: defaults = DEFAULT_DATASOURCES.get(datasource.source, {}) return { "id": datasource.id, "name": datasource.name, "display_name": defaults.get("display_name") or datasource.name, "source": datasource.source, "module": datasource.module, "priority": datasource.priority, "frequency_minutes": datasource.frequency_minutes, "frequency": format_frequency_label(datasource.frequency_minutes), "is_active": datasource.is_active, "last_run_at": to_iso8601_utc(datasource.last_run_at), "last_status": datasource.last_status, "next_run_at": to_iso8601_utc(datasource.next_run_at), "is_free": bool(defaults.get("is_free", True)), "requires_credentials": bool(defaults.get("requires_credentials", False)), "credential_provider": defaults.get("credential_provider"), "credential_status": defaults.get("credential_status", "none"), } @router.get("/system") async def get_system_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"system": await get_setting_payload(db, "system")} @router.put("/system") async def update_system_settings( settings: SystemSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "system", settings.model_dump()) return {"status": "updated", "system": payload} @router.get("/notifications") async def get_notification_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"notifications": await get_setting_payload(db, "notifications")} @router.put("/notifications") async def update_notification_settings( settings: NotificationSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "notifications", settings.model_dump()) return {"status": "updated", "notifications": payload} @router.get("/security") async def get_security_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"security": await get_setting_payload(db, "security")} @router.put("/security") async def update_security_settings( settings: SecuritySettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = await save_setting_payload(db, "security", settings.model_dump()) return {"status": "updated", "security": payload} @router.get("/tv") async def get_tv_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"tv": await get_tv_settings_payload(db)} @router.put("/tv") async def update_tv_settings( settings: TVSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): payload = normalize_tv_settings(settings.model_dump()) saved = await save_setting_payload(db, "tv", payload) return {"status": "updated", "tv": normalize_tv_settings(saved)} @router.get("/integrations") async def get_external_integrations( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return {"integrations": await serialize_external_integrations(db)} @router.get("/integrations/barentswatch/connectivity") async def get_barentswatch_connectivity( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await check_barentswatch_connectivity(db) @router.post("/integrations/barentswatch/connect") async def connect_barentswatch_integration( payload: BarentsWatchIntegrationUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): current = await resolve_barentswatch_config(db) config = BarentsWatchConfig( endpoint=payload.endpoint.strip() or current.endpoint, client_id=payload.client_id.strip() or current.client_id, client_secret=( "" if payload.clear_client_secret else payload.client_secret or current.client_secret ), credential_source="draft", endpoint_source="draft", ) result = await check_barentswatch_config(config) if result.get("success"): checksum, _context = await build_builtin_connectivity_checksum( "barentswatch_vessels", config.endpoint, "none", {}, {}, db, credential_override={ "client_id": config.client_id, "client_secret": config.client_secret, }, ) validation = await save_connectivity_success( db, "barentswatch_vessels", checksum, result, connected_by="connection_button", ) await db.commit() return {**result, "connected": True, "validation": validation} return {**result, "connected": False} @router.get("/credential-guides/{provider}") async def read_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: return {"guide": await get_credential_guide(db, provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/credential-guides/{provider}/generate") async def generate_provider_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ai_client: AIProviderClient = Depends(get_ai_provider_client), ): try: return {"guide": await generate_credential_guide(db, provider, ai_client)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/credential-guides/{provider}/reset") async def reset_provider_credential_guide( provider: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): try: return {"guide": await reset_credential_guide(db, provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @router.get("/integrations/ai-provider/presets") async def get_ai_provider_presets( current_user: User = Depends(get_current_user), ): return {"data": list_fallback_llm_provider_presets()} @router.post("/integrations/ai-provider/presets/{provider}/refresh") async def refresh_ai_provider_preset( provider: str, current_user: User = Depends(get_current_user), ): try: return {"data": await refresh_llm_provider_preset(provider)} except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except Exception as exc: fallback = get_fallback_llm_provider_preset(provider) fallback["refresh_error"] = str(exc) return {"data": fallback} @router.put("/integrations") async def update_external_integrations( payload: ExternalIntegrationsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): saved = await save_external_integrations_payload(db, payload) return {"status": "updated", "integrations": saved} @router.get("/collectors") async def get_collector_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) datasources = result.scalars().all() return {"collectors": [serialize_collector(datasource) for datasource in datasources]} @router.put("/collectors/{datasource_id}") async def update_collector_settings( datasource_id: int, settings: CollectorSettingsUpdate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): datasource = await db.get(DataSource, datasource_id) if not datasource: raise HTTPException(status_code=404, detail="Data source not found") datasource.is_active = settings.is_active datasource.priority = settings.priority datasource.frequency_minutes = settings.frequency_minutes await db.commit() await db.refresh(datasource) await sync_datasource_job(datasource.id) return {"status": "updated", "collector": serialize_collector(datasource)} @router.get("") async def get_all_settings( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id)) datasources = result.scalars().all() setting_payloads = await get_setting_payloads( db, ["system", "notifications", "security"], ) return { "system": setting_payloads["system"], "notifications": setting_payloads["notifications"], "security": setting_payloads["security"], "tv": await get_tv_settings_payload(db), "integrations": await serialize_external_integrations(db), "collectors": [serialize_collector(datasource) for datasource in datasources], "generated_at": to_iso8601_utc(datetime.now(UTC)), }