"""data_analytics 插件 API:凭证 CRUD + 分析任务提交/查询/事件流。 挂载后路径前缀为 /api/v1/plugin/data_analytics/...,跟核心 API 完全独立。 所有数据库读写都走 organization actor 的代理方法(确保分布式模式下不跨 actor 共享 SQLAlchemy session)。 """ from __future__ import annotations from typing import Optional from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field from kilostar.utils.access import Accessor, TokenData from kilostar.utils.actor import get_actor router = APIRouter(tags=["data_analytics"]) # ─── Schemas ──────────────────────────────────────────────────────────────── class CredentialCreate(BaseModel): display_name: str = Field(..., max_length=100) endpoint_url: Optional[str] = None region: str = "us-east-1" access_key: str = Field(..., min_length=1) secret_key: str = Field(..., min_length=1) class JobCreate(BaseModel): cred_id: str description: str = Field(..., min_length=1, max_length=2000) # ─── Helpers ──────────────────────────────────────────────────────────────── def _get_org(): try: return get_actor("org_data_analytics") except Exception as e: raise HTTPException(503, f"data_analytics 插件未就绪:{e}") # ─── Credentials ──────────────────────────────────────────────────────────── @router.get("/credentials") async def list_credentials( token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() rows = await org.cred_list(token_data.username) return {"credentials": rows} @router.post("/credentials") async def create_credential( payload: CredentialCreate, token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() row = await org.cred_create( user_id=token_data.username, display_name=payload.display_name, access_key=payload.access_key, secret_key=payload.secret_key, endpoint_url=payload.endpoint_url, region=payload.region, ) return row @router.delete("/credentials/{cred_id}") async def delete_credential( cred_id: str, token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() ok = await org.cred_delete(cred_id, token_data.username) if not ok: raise HTTPException(404, "凭证不存在或不属于当前用户") return {"status": "ok"} # ─── Jobs ─────────────────────────────────────────────────────────────────── @router.post("/jobs") async def create_job( payload: JobCreate, token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() try: return await org.job_create( user_id=token_data.username, cred_id=payload.cred_id, description=payload.description, ) except ValueError as e: raise HTTPException(400, str(e)) @router.get("/jobs") async def list_jobs( token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() rows = await org.job_list(token_data.username) return {"jobs": rows} @router.get("/jobs/{job_id}") async def get_job( job_id: str, token_data: TokenData = Depends(Accessor.get_current_user), ): org = _get_org() row = await org.job_get(job_id, token_data.username) if row is None: raise HTTPException(404, "任务不存在") return row @router.get("/jobs/{job_id}/stream") async def stream_job( job_id: str, token_data: TokenData = Depends(Accessor.get_current_user), ): """转发 organization 事件流为 SSE。""" import json org = _get_org() row = await org.job_get(job_id, token_data.username) if row is None: raise HTTPException(404, "任务不存在") org_task_id = row.get("org_task_id") if not org_task_id: raise HTTPException(409, "任务尚未投递到 organization") async def _generate(): async for event in await org.stream(org_task_id): payload = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False) yield f"data: {payload}\n\n" return StreamingResponse(_generate(), media_type="text/event-stream")