Files
planet/backend/app/api/v1/settings.py
2026-05-11 09:49:08 +08:00

1283 lines
48 KiB
Python

from copy import deepcopy
from datetime import UTC, datetime
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, EmailStr, Field
from dotenv import dotenv_values
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.models.vessel import AISSourceHealth
from app.schemas.ai import SituationalAnalysisRequest
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.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
from app.services.ai_tools.web_search import (
WebSearchClient,
WebSearchConfigurationError,
WebSearchError,
get_web_search_provider_preset,
list_web_search_provider_presets,
normalize_web_search_provider,
provider_defaults as web_search_provider_defaults,
)
from app.services.llm_provider_catalog import (
FALLBACK_LLM_PROVIDER_PRESETS,
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": "",
"default_provider": "minimax",
"providers": {},
"timeout_seconds": 60,
"retry_attempts": 2,
},
"web_search": {
"enabled": False,
"default_provider": "tavily",
"providers": {},
},
},
}
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
default_provider: 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 WebSearchIntegrationUpdate(BaseModel):
enabled: bool = False
default_provider: Optional[str] = None
provider: str = Field(default="tavily", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
max_results: int = Field(default=5, ge=1, le=20)
timeout_seconds: int = Field(default=20, ge=3, le=120)
endpoint_path: str = Field(default="", max_length=200)
search_depth: str = Field(default="basic", max_length=40)
engine: str = Field(default="google", max_length=80)
include_answer: bool = False
include_raw_content: bool = False
include_text: bool = False
categories: str = Field(default="general", max_length=120)
engines: list[str] = Field(default_factory=list)
search_path: str = Field(default="", max_length=200)
scrape_path: str = Field(default="", max_length=200)
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
web_search: WebSearchIntegrationUpdate | None = None
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)
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
WEB_SEARCH_ENV_FILES = (
Path(__file__).resolve().parents[4] / ".env",
Path(__file__).resolve().parents[3] / ".env",
AI_PROVIDER_ENV_FILE,
)
def _mask_secret(value: Optional[str], source: str = "") -> dict:
if not value:
return {"configured": False, "preview": "", "source": source}
text = str(value)
if "-" in text:
prefix = text.split("-", 1)[0] + "-"
preview = prefix + ("*" * max(len(text) - len(prefix), 1))
else:
preview = "*" * len(text)
return {"configured": True, "preview": preview, "source": source}
def _normalize_provider_id(provider: Optional[str]) -> str:
return (provider or "minimax").strip().lower() or "minimax"
def _get_provider_preset(provider: str) -> dict:
try:
return get_fallback_llm_provider_preset(provider)
except ValueError:
return {
"provider": provider,
"provider_api": "openai-completions",
"base_url": "",
"model": "",
"models": [],
"api_key_env": "",
}
def _read_ai_provider_env_file() -> dict[str, str]:
if not AI_PROVIDER_ENV_FILE.exists():
return {}
return {
key: str(value)
for key, value in dotenv_values(AI_PROVIDER_ENV_FILE).items()
if value is not None
}
def _resolve_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_ai_provider_env_file()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
return "", ""
def _read_web_search_env_files() -> dict[str, str]:
values: dict[str, str] = {}
for path in WEB_SEARCH_ENV_FILES:
if not path.exists():
continue
values.update({
key: str(value)
for key, value in dotenv_values(path).items()
if value is not None
})
return values
def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_web_search_env_files()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
value = os.environ.get(name)
if value:
return value, "env"
return "", ""
def _provider_defaults(provider: str) -> dict:
preset = _get_provider_preset(provider)
return {
"provider": provider,
"provider_api": preset.get("provider_api") or "openai-completions",
"base_url": preset.get("base_url") or "",
"model": preset.get("model") or "",
"api_key": "",
"max_tokens": (
1200 if preset.get("provider_api") == "anthropic-messages" else 4096
),
"anthropic_version": "2023-06-01",
}
def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
raw = dict(ai_payload or {})
default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider"))
providers = {
_normalize_provider_id(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"provider_api",
"base_url",
"model",
"api_key",
"max_tokens",
"anthropic_version",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = _normalize_provider_id(provider)
normalized_providers[provider_id] = {
**_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _provider_defaults(default_provider)
return {
"service_url": raw.get("service_url") or "",
"service_token": raw.get("service_token") or "",
"default_provider": default_provider,
"providers": normalized_providers,
"timeout_seconds": int(raw.get("timeout_seconds") or 60),
"retry_attempts": int(raw.get("retry_attempts") or 2),
}
def _resolve_provider_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = _get_provider_preset(provider)
api_key_env = preset.get("api_key_env") or ""
return _resolve_env_secret(api_key_env, "AI_API_KEY")
def _resolve_service_token(ai_payload: dict) -> tuple[str, str]:
saved_token = ai_payload.get("service_token") or ""
if saved_token:
return str(saved_token), "runtime"
token, source = _resolve_env_secret("AI_PROVIDER_SERVICE_TOKEN")
if token:
return token, source
if app_settings.AI_PROVIDER_SERVICE_TOKEN:
return app_settings.AI_PROVIDER_SERVICE_TOKEN, "backend_env"
return "", ""
def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> bool:
if value in (None, ""):
return True
text = str(value).strip()
if not text:
return True
return text == current_preview or text.startswith("••••") or "*" in text
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(update.default_provider or update.provider)
current_providers = {
provider: dict(config or {})
for provider, config in current_ai.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _provider_defaults(provider_id)
current_api_key, current_api_key_source = _resolve_provider_api_key(provider_id, current_provider)
current_api_key_preview = _mask_secret(current_api_key, current_api_key_source)["preview"]
provider_payload = {
**_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"provider_api": update.provider_api.strip()
or current_provider.get("provider_api")
or "anthropic-messages",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"max_tokens": update.max_tokens,
"anthropic_version": update.anthropic_version.strip() or "2023-06-01",
}
if not _is_secret_placeholder(update.api_key, current_api_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
current_service_token, current_service_source = _resolve_service_token(current_ai)
current_service_preview = _mask_secret(current_service_token, current_service_source)["preview"]
ai_payload = {
"service_url": update.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"default_provider": provider_id,
"providers": current_providers,
"timeout_seconds": update.timeout_seconds,
"retry_attempts": update.retry_attempts,
}
if not _is_secret_placeholder(update.service_token, current_service_preview):
ai_payload["service_token"] = str(update.service_token).strip()
return ai_payload
def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
normalized_ai = _normalize_ai_provider_payload(ai_payload)
default_provider = normalized_ai["default_provider"]
provider_config = (
normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider)
)
api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config)
return {
"service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": _resolve_service_token(normalized_ai)[0],
"timeout_seconds": int(
normalized_ai.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
normalized_ai.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": default_provider,
"provider_api": provider_config.get("provider_api") or "anthropic-messages",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": api_key,
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
},
}
def _web_search_provider_defaults(provider: str) -> dict:
return web_search_provider_defaults(provider).model_dump()
def _normalize_web_search_payload(web_search_payload: dict | None) -> dict:
raw = dict(web_search_payload or {})
default_provider = normalize_web_search_provider(
raw.get("default_provider") or raw.get("provider")
)
providers = {
normalize_web_search_provider(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"base_url",
"api_key",
"max_results",
"timeout_seconds",
"endpoint_path",
"search_depth",
"engine",
"include_answer",
"include_raw_content",
"include_text",
"categories",
"engines",
"search_path",
"scrape_path",
"scrape_formats",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = normalize_web_search_provider(provider)
normalized_providers[provider_id] = {
**_web_search_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _web_search_provider_defaults(default_provider)
return {
"enabled": bool(raw.get("enabled", False)),
"default_provider": default_provider,
"providers": normalized_providers,
}
def _resolve_web_search_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = get_web_search_provider_preset(provider)
return _resolve_web_search_env_secret(preset.get("api_key_env") or "", "WEB_SEARCH_API_KEY")
def _build_web_search_payload(
current_payload: dict,
update: WebSearchIntegrationUpdate | None,
) -> dict:
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
if update is None:
return current_web_search
provider_id = normalize_web_search_provider(update.default_provider or update.provider)
current_providers = {
provider: dict(config or {})
for provider, config in current_web_search.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id)
current_key, current_key_source = _resolve_web_search_api_key(provider_id, current_provider)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
provider_payload = {
**_web_search_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"base_url": update.base_url.strip()
or current_provider.get("base_url")
or _web_search_provider_defaults(provider_id).get("base_url")
or "",
"max_results": update.max_results,
"timeout_seconds": update.timeout_seconds,
"endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "",
"search_depth": update.search_depth.strip() or "basic",
"engine": update.engine.strip() or "google",
"include_answer": update.include_answer,
"include_raw_content": update.include_raw_content,
"include_text": update.include_text,
"categories": update.categories.strip() or "general",
"engines": update.engines,
"search_path": update.search_path.strip() or current_provider.get("search_path") or "",
"scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "",
"scrape_formats": update.scrape_formats or ["markdown"],
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
return {
"enabled": update.enabled,
"default_provider": provider_id,
"providers": current_providers,
}
def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig:
normalized = _normalize_web_search_payload(web_search_payload)
provider_id = normalized["default_provider"]
provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id)
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config)
provider_models = {
provider: WebSearchProviderConfig(**{
**config,
"api_key": (
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config)[0]
),
})
for provider, config in normalized["providers"].items()
}
return WebSearchConfig(
enabled=normalized["enabled"],
default_provider=provider_id,
provider=provider_id,
providers=provider_models,
)
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
"external_integrations",
runtime_record.payload if runtime_record else None,
)
return _runtime_config_from_web_search_payload(payload.get("web_search") or {})
async def get_web_search_client(db: AsyncSession) -> WebSearchClient:
return WebSearchClient(await get_runtime_web_search_config(db))
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,
)
return _runtime_config_from_ai_payload(payload.get("ai_provider") or {})
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")
raw_payload = merge_with_defaults(
"external_integrations",
runtime_setting.payload if runtime_setting else None,
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
*FALLBACK_LLM_PROVIDER_PRESETS.keys(),
*normalized_ai["providers"].keys(),
default_provider,
}):
provider_id = _normalize_provider_id(provider)
provider_config = normalized_ai["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
providers_payload[provider_id] = {
"provider": provider_id,
"provider_api": provider_config.get("provider_api") or "openai-completions",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": _mask_secret(api_key, api_key_source),
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
web_search_providers_payload: dict[str, dict] = {}
for provider in sorted({
*[preset["provider"] for preset in list_web_search_provider_presets()],
*normalized_web_search["providers"].keys(),
normalized_web_search["default_provider"],
}):
provider_id = normalize_web_search_provider(provider)
provider_config = (
normalized_web_search["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
web_search_providers_payload[provider_id] = {
**{
key: value
for key, value in provider_config.items()
if key != "api_key"
},
"provider": provider_id,
"api_key": _mask_secret(api_key, api_key_source),
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_web_search_config = (
web_search_providers_payload.get(normalized_web_search["default_provider"])
or _web_search_provider_defaults(normalized_web_search["default_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(*_resolve_service_token(normalized_ai)),
"default_provider": default_provider,
"provider": default_provider,
"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": display_llm_config.get("api_key") or _mask_secret(None),
"providers": providers_payload,
"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,
},
"web_search": {
"enabled": normalized_web_search["enabled"],
"default_provider": normalized_web_search["default_provider"],
"provider": normalized_web_search["default_provider"],
"base_url": display_web_search_config.get("base_url") or "",
"api_key": display_web_search_config.get("api_key") or _mask_secret(None),
"providers": web_search_providers_payload,
"max_results": int(display_web_search_config.get("max_results") or 5),
"timeout_seconds": int(display_web_search_config.get("timeout_seconds") or 20),
"endpoint_path": display_web_search_config.get("endpoint_path") or "",
"search_depth": display_web_search_config.get("search_depth") or "basic",
"engine": display_web_search_config.get("engine") or "google",
"include_answer": bool(display_web_search_config.get("include_answer", False)),
"include_raw_content": bool(display_web_search_config.get("include_raw_content", False)),
"include_text": bool(display_web_search_config.get("include_text", False)),
"categories": display_web_search_config.get("categories") or "general",
"engines": display_web_search_config.get("engines") or [],
"search_path": display_web_search_config.get("search_path") or "",
"scrape_path": display_web_search_config.get("scrape_path") or "",
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
"source": "runtime" if runtime_setting else "env",
},
}
async def save_external_integrations_payload(
db: AsyncSession,
update: ExternalIntegrationsUpdate,
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": ai_payload, "web_search": web_search_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"
async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]:
result = await db.execute(select(AISSourceHealth))
return {item.source: item.to_dict() for item in result.scalars().all()}
def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> 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"),
"ais_health": (ais_health_by_source or {}).get(datasource.source),
}
@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.post("/integrations/ai-provider/connect")
async def connect_ai_provider_integration(
payload: AIProviderIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
try:
status_result = await client.get_status()
if not status_result.configured:
return {
"success": False,
"connected": False,
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="连接测试",
objective="请用一句话回复连接可用。",
observations=["这是配置中心发起的 LLM 连接测试。"],
constraints=["回复尽量简短。"],
)
)
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": draft_ai_payload, "web_search": current_web_search},
)
return {
"success": True,
"connected": True,
"message": "AI Provider 连接成功,已保存为全局默认配置。",
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
"integrations": await serialize_external_integrations(db),
}
except HTTPException as exc:
return {
"success": False,
"connected": False,
"message": str(exc.detail),
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"AI Provider 连接测试失败: {exc}",
}
@router.get("/integrations/ai-provider/secrets")
async def reveal_ai_provider_secrets(
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(provider or ai_payload["default_provider"])
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
service_token, service_token_source = _resolve_service_token(ai_payload)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
"service_token": service_token,
"service_token_source": service_token_source,
}
@router.get("/integrations/web-search/presets")
async def get_web_search_presets(
current_user: User = Depends(get_current_user),
):
return {"data": list_web_search_provider_presets()}
@router.get("/integrations/web-search/secrets")
async def reveal_web_search_secrets(
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {})
provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"])
provider_config = (
web_search_payload["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(provider_id, provider_config)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.post("/integrations/web-search/connect")
async def connect_web_search_integration(
payload: WebSearchIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_web_search_payload = _build_web_search_payload(current_payload, payload)
runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload)
client = WebSearchClient(runtime_config)
try:
results = await client.test_connection()
return {
"success": True,
"connected": True,
"message": "WebSearch 连接成功。",
"provider": runtime_config.default_provider,
"results": [item.model_dump(mode="json") for item in results[:3]],
}
except WebSearchConfigurationError as exc:
return {
"success": False,
"connected": False,
"message": str(exc),
}
except WebSearchError as exc:
return {
"success": False,
"connected": False,
"message": str(exc),
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"WebSearch 连接测试失败: {exc}",
}
@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:
web_search_client = await get_web_search_client(db)
return {
"guide": await generate_credential_guide(
db,
provider,
ai_client,
web_search_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()
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"collectors": [serialize_collector(datasource, ais_health_by_source) 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)
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)}
@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"],
)
ais_health_by_source = await get_ais_source_health_by_source(db)
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, ais_health_by_source) for datasource in datasources],
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}