546 lines
20 KiB
Python
546 lines
20 KiB
Python
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.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]:
|
|
result = await db.execute(
|
|
select(DataSourceConfig)
|
|
.where(DataSourceConfig.name == "barentswatch_vessels")
|
|
.where(DataSourceConfig.is_active.is_(True))
|
|
)
|
|
return result.scalar_one_or_none()
|
|
|
|
|
|
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)
|
|
yaml_config = get_data_sources_config()
|
|
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
|
|
barentswatch_auth = barentswatch_auth or {}
|
|
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": (
|
|
barentswatch_record.endpoint
|
|
if barentswatch_record and barentswatch_record.endpoint
|
|
else yaml_config.get_yaml_url("barentswatch_vessels")
|
|
),
|
|
"client_id": barentswatch_auth.get("client_id") or "",
|
|
"client_secret": _mask_secret(barentswatch_auth.get("client_secret")),
|
|
"source": "datasource_config" if barentswatch_record else "default",
|
|
},
|
|
}
|
|
|
|
|
|
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_credentials",
|
|
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_credentials"
|
|
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/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)),
|
|
}
|