Root cause: ensure_structured_column() executed a synchronous blocking engine.connect().execute(SELECT information_schema.columns ...) inside the async request path (_build_chat_history / _maybe_compress_history), freezing the uvicorn event loop and hanging all endpoints (~200s, recovered only by docker restart). Fix: - chat_context.py: remove per-request ensure_structured_column() calls; run once idempotently at app startup (main.py lifespan). Use CURRENT_DATABASE() for PostgreSQL compatibility. - main.py: add startup-only ensure_structured_column() hook (try/except). - llm_client.py: trust_env=False + bounded httpx.Timeout/wait_for so broken endpoints fail fast instead of hanging the event loop. - chat_service.py: run media generation in a background daemon thread with its own event loop (_launch_media_task_bg) so SSE end does not cancel image/video tasks (#811); remove TEMP faulthandler diagnostic. Safe-align: persist accumulated live container changes (admin/*, knowledge, market, models, config, observability, mcp_client, platform_knowledge, im_bot_platform, chat_media, capability_registry, requirements) and add workflow_templates. Verified: live failover test (model-006 -> conn-refused) returns in ~1.9s with concurrent login 0.83s/200; event-loop responsiveness restored (sub-second).
753 lines
27 KiB
Python
753 lines
27 KiB
Python
"""
|
|
Distribution Builder Admin API - Phase G4
|
|
发行版构建器:SaaS 产品线管理,配置包生成,许可证管理
|
|
|
|
Endpoints:
|
|
GET / - 发行版列表
|
|
POST / - 创建发行版
|
|
GET /{id} - 发行版详情
|
|
PUT /{id} - 更新发行版
|
|
DELETE /{id} - 删除发行版
|
|
POST /{id}/build - 构建配置包
|
|
GET /{id}/download - 下载配置包
|
|
POST /{id}/licenses - 生成许可证
|
|
GET /licenses - 许可证列表
|
|
GET /licenses/{lid} - 许可证详情
|
|
POST /licenses/{lid}/revoke - 吊销许可证
|
|
POST /licenses/validate - 验证许可证
|
|
GET /stats - 发行版统计
|
|
GET /templates - 发行版模板
|
|
"""
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import uuid
|
|
import yaml
|
|
import zipfile
|
|
import io
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Optional, List
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import StreamingResponse
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import text
|
|
|
|
from ...database import get_db, SessionLocal
|
|
from ...auth import get_current_admin
|
|
|
|
router = APIRouter()
|
|
|
|
CST = timezone(timedelta(hours=8))
|
|
LICENSE_SECRET = "qihuaiflow-distro-secret-key-2026"
|
|
|
|
# ==================== Pydantic Models ====================
|
|
|
|
class DistroCreate(BaseModel):
|
|
name: str = Field(..., description="发行版名称")
|
|
description: str = Field("")
|
|
brand_name: Optional[str] = None
|
|
brand_logo_url: Optional[str] = None
|
|
brand_color: str = "#6366f1"
|
|
system_prompt: str = ""
|
|
provider: str = "siliconflow"
|
|
model: str = "deepseek-ai/DeepSeek-V4-Flash"
|
|
mcp_extensions: Optional[List[dict]] = None
|
|
workflow_ids: Optional[List[str]] = None
|
|
knowledge_base_ids: Optional[List[str]] = None
|
|
agent_ids: Optional[List[str]] = None
|
|
features: Optional[List[str]] = None
|
|
version: str = "1.0.0"
|
|
|
|
class DistroUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
brand_name: Optional[str] = None
|
|
brand_logo_url: Optional[str] = None
|
|
brand_color: Optional[str] = None
|
|
system_prompt: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
model: Optional[str] = None
|
|
mcp_extensions: Optional[List[dict]] = None
|
|
workflow_ids: Optional[List[str]] = None
|
|
knowledge_base_ids: Optional[List[str]] = None
|
|
agent_ids: Optional[List[str]] = None
|
|
features: Optional[List[str]] = None
|
|
version: Optional[str] = None
|
|
status: Optional[str] = None
|
|
|
|
class LicenseCreate(BaseModel):
|
|
distribution_id: str
|
|
org_name: str
|
|
licensee_email: Optional[str] = None
|
|
features: Optional[List[str]] = None
|
|
max_users: int = 10
|
|
max_executions: int = 10000
|
|
expiry_date: Optional[str] = None # YYYY-MM-DD
|
|
|
|
class LicenseValidate(BaseModel):
|
|
license_key: str
|
|
distribution_id: Optional[str] = None
|
|
|
|
# ==================== Helpers ====================
|
|
|
|
def _execute_raw(db, sql, params=None):
|
|
return db.execute(text(sql), params or {})
|
|
|
|
def _generate_id(prefix="distro"):
|
|
return f"{prefix}-{uuid.uuid4().hex[:8]}"
|
|
|
|
def _format_cst_time(dt):
|
|
if dt is None:
|
|
return None
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=CST)
|
|
return dt.astimezone(CST)
|
|
|
|
def _row_to_dict(row, columns):
|
|
return {col: row[i] for i, col in enumerate(columns)}
|
|
|
|
def _serialize_dt(obj):
|
|
if isinstance(obj, datetime):
|
|
return _format_cst_time(obj).strftime("%Y-%m-%d %H:%M:%S")
|
|
return obj
|
|
|
|
# ==================== Distribution CRUD ====================
|
|
|
|
@router.get("", summary="发行版列表")
|
|
async def list_distributions(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
status: Optional[str] = None,
|
|
search: Optional[str] = None,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
conditions = []
|
|
params = {}
|
|
if status:
|
|
conditions.append("status = :status")
|
|
params["status"] = status
|
|
if search:
|
|
conditions.append("(name LIKE :search OR description LIKE :search)")
|
|
params["search"] = f"%{search}%"
|
|
|
|
where = " AND ".join(conditions) if conditions else "1=1"
|
|
|
|
rows = _execute_raw(db, f"SELECT COUNT(*) FROM distributions WHERE {where}", params)
|
|
total = rows.fetchone()[0]
|
|
|
|
offset = (page - 1) * page_size
|
|
rows = _execute_raw(db,
|
|
f"SELECT * FROM distributions WHERE {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset",
|
|
{**params, "limit": page_size, "offset": offset}
|
|
)
|
|
cols = list(rows.keys())
|
|
items = []
|
|
for row in rows.fetchall():
|
|
d = _row_to_dict(row, cols)
|
|
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
|
|
if d.get(key) and isinstance(d[key], str):
|
|
try:
|
|
d[key] = json.loads(d[key])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
for key in ["created_at", "updated_at"]:
|
|
d[key] = _serialize_dt(d[key])
|
|
items.append(d)
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {"total": total, "items": items, "page": page, "page_size": page_size}
|
|
}
|
|
|
|
@router.post("", summary="创建发行版")
|
|
async def create_distribution(
|
|
body: DistroCreate,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
distro_id = _generate_id("distro")
|
|
now = datetime.now()
|
|
|
|
config_json = json.dumps({
|
|
"provider": body.provider,
|
|
"model": body.model,
|
|
"mcp_extensions": body.mcp_extensions or [],
|
|
"workflow_ids": body.workflow_ids or [],
|
|
"knowledge_base_ids": body.knowledge_base_ids or [],
|
|
"agent_ids": body.agent_ids or [],
|
|
"features": body.features or ["chat"],
|
|
}, ensure_ascii=False)
|
|
|
|
_execute_raw(db, """
|
|
INSERT INTO distributions (id, name, description, brand_name, brand_logo_url, brand_color,
|
|
system_prompt, provider, model, mcp_extensions, workflow_ids, knowledge_base_ids,
|
|
agent_ids, features, config_json, status, version, created_at, updated_at)
|
|
VALUES (:id, :name, :desc, :brand_name, :brand_logo, :brand_color,
|
|
:system_prompt, :provider, :model, :mcp_ext, :wf_ids, :kb_ids,
|
|
:agent_ids, :features, :config_json, 'draft', :version, :now, :now)
|
|
""", {
|
|
"id": distro_id, "name": body.name, "desc": body.description,
|
|
"brand_name": body.brand_name, "brand_logo": body.brand_logo_url, "brand_color": body.brand_color,
|
|
"system_prompt": body.system_prompt, "provider": body.provider, "model": body.model,
|
|
"mcp_ext": json.dumps(body.mcp_extensions) if body.mcp_extensions else None,
|
|
"wf_ids": json.dumps(body.workflow_ids) if body.workflow_ids else None,
|
|
"kb_ids": json.dumps(body.knowledge_base_ids) if body.knowledge_base_ids else None,
|
|
"agent_ids": json.dumps(body.agent_ids) if body.agent_ids else None,
|
|
"features": json.dumps(body.features) if body.features else None,
|
|
"config_json": config_json, "version": body.version, "now": now,
|
|
})
|
|
db.commit()
|
|
|
|
return {"code": 0, "data": {"id": distro_id}, "message": "发行版创建成功"}
|
|
|
|
@router.get("/templates", summary="发行版模板")
|
|
async def get_templates(
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""获取可用资源模板:工作流、Agent、知识库、MCP扩展"""
|
|
# Get available workflows (any status)
|
|
try:
|
|
rows = _execute_raw(db, "SELECT id, name, status FROM agent_workflows ORDER BY updated_at DESC LIMIT 20")
|
|
cols = list(rows.keys())
|
|
workflows = [_row_to_dict(r, cols) for r in rows.fetchall()]
|
|
except Exception:
|
|
workflows = []
|
|
|
|
# Get available agents
|
|
try:
|
|
rows = _execute_raw(db, "SELECT id, name, description FROM market_apps WHERE type='agent' AND status='published' LIMIT 20")
|
|
cols = list(rows.keys())
|
|
agents = [_row_to_dict(r, cols) for r in rows.fetchall()]
|
|
except Exception:
|
|
agents = []
|
|
|
|
# Get available knowledge bases
|
|
rows = _execute_raw(db, "SELECT id, name, description FROM knowledge_bases WHERE status='active' LIMIT 20")
|
|
cols = list(rows.keys())
|
|
kbs = [_row_to_dict(r, cols) for r in rows.fetchall()]
|
|
|
|
# Get available MCP tools
|
|
try:
|
|
rows = _execute_raw(db, "SELECT id, app_id as name, server_type FROM mcp_tool_configs WHERE security_status='approved' LIMIT 20")
|
|
cols = list(rows.keys())
|
|
mcps = [_row_to_dict(r, cols) for r in rows.fetchall()]
|
|
except Exception:
|
|
mcps = []
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"workflows": workflows,
|
|
"agents": agents,
|
|
"knowledge_bases": kbs,
|
|
"mcp_tools": mcps,
|
|
}
|
|
}
|
|
|
|
@router.get("/stats", summary="发行版统计")
|
|
async def get_stats(
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, """
|
|
SELECT
|
|
COUNT(*) as total,
|
|
SUM(CASE WHEN status='draft' THEN 1 ELSE 0 END) as draft,
|
|
SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active,
|
|
SUM(CASE WHEN status='archived' THEN 1 ELSE 0 END) as archived,
|
|
SUM(download_count) as total_downloads,
|
|
(SELECT COUNT(*) FROM distribution_licenses) as total_licenses,
|
|
(SELECT COUNT(*) FROM distribution_licenses WHERE status='active') as active_licenses
|
|
FROM distributions
|
|
""")
|
|
row = rows.fetchone()
|
|
cols = list(rows.keys())
|
|
stats = _row_to_dict(row, cols)
|
|
|
|
return {"code": 0, "data": stats}
|
|
|
|
# ==================== License Management ====================
|
|
|
|
def _generate_license_key(distro_id: str, org_name: str, features: list, expiry: str) -> str:
|
|
"""Generate HMAC-SHA256 license key"""
|
|
payload = f"{distro_id}:{org_name}:{json.dumps(sorted(features or []))}:{expiry or 'perpetual'}"
|
|
return hmac.new(
|
|
LICENSE_SECRET.encode(),
|
|
payload.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()[:32]
|
|
|
|
def _verify_license_signature(license_key: str, payload: str) -> bool:
|
|
"""Verify license key signature"""
|
|
expected = hmac.new(
|
|
LICENSE_SECRET.encode(),
|
|
payload.encode(),
|
|
hashlib.sha256
|
|
).hexdigest()[:32]
|
|
return hmac.compare_digest(expected, license_key)
|
|
|
|
@router.get("/licenses", summary="许可证列表")
|
|
async def list_licenses(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(20, ge=1, le=100),
|
|
distribution_id: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
conditions = []
|
|
params = {}
|
|
if distribution_id:
|
|
conditions.append("l.distribution_id = :did")
|
|
params["did"] = distribution_id
|
|
if status:
|
|
conditions.append("l.status = :status")
|
|
params["status"] = status
|
|
|
|
where = " AND ".join(conditions) if conditions else "1=1"
|
|
|
|
rows = _execute_raw(db, f"SELECT COUNT(*) FROM distribution_licenses l WHERE {where}", params)
|
|
total = rows.fetchone()[0]
|
|
|
|
offset = (page - 1) * page_size
|
|
rows = _execute_raw(db, f"""
|
|
SELECT l.*, d.name as distribution_name
|
|
FROM distribution_licenses l
|
|
LEFT JOIN distributions d ON l.distribution_id = d.id
|
|
WHERE {where}
|
|
ORDER BY l.created_at DESC
|
|
LIMIT :limit OFFSET :offset
|
|
""", {**params, "limit": page_size, "offset": offset})
|
|
|
|
cols = list(rows.keys())
|
|
items = []
|
|
for row in rows.fetchall():
|
|
item = _row_to_dict(row, cols)
|
|
for key in ["features", "metadata_json"]:
|
|
if item.get(key) and isinstance(item[key], str):
|
|
try:
|
|
item[key] = json.loads(item[key])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]:
|
|
item[key] = _serialize_dt(item[key])
|
|
if item.get("expiry_date"):
|
|
item["expiry_date"] = str(item["expiry_date"])
|
|
items.append(item)
|
|
|
|
return {"code": 0, "data": {"total": total, "items": items, "page": page, "page_size": page_size}}
|
|
|
|
@router.post("/{distro_id}/licenses", summary="生成许可证")
|
|
async def create_license(
|
|
distro_id: str,
|
|
body: LicenseCreate,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, "SELECT id, features FROM distributions WHERE id = :id", {"id": distro_id})
|
|
row = rows.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="发行版不存在")
|
|
|
|
distro_features = row[1]
|
|
if isinstance(distro_features, str):
|
|
try:
|
|
distro_features = json.loads(distro_features)
|
|
except json.JSONDecodeError:
|
|
distro_features = []
|
|
|
|
license_id = _generate_id("lic")
|
|
# Features limited to what distribution supports
|
|
requested_features = body.features or distro_features or ["chat"]
|
|
license_key = _generate_license_key(distro_id, body.org_name, requested_features, body.expiry_date)
|
|
|
|
now = datetime.now()
|
|
_execute_raw(db, """
|
|
INSERT INTO distribution_licenses
|
|
(id, distribution_id, org_name, licensee_email, license_key, features,
|
|
max_users, max_executions, expiry_date, status, created_at, updated_at)
|
|
VALUES (:id, :did, :org, :email, :key, :features,
|
|
:max_users, :max_exec, :expiry, 'active', :now, :now)
|
|
""", {
|
|
"id": license_id, "did": distro_id, "org": body.org_name, "email": body.licensee_email,
|
|
"key": license_key, "features": json.dumps(requested_features),
|
|
"max_users": body.max_users, "max_exec": body.max_executions,
|
|
"expiry": body.expiry_date, "now": now,
|
|
})
|
|
db.commit()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"id": license_id,
|
|
"license_key": license_key,
|
|
"org_name": body.org_name,
|
|
"features": requested_features,
|
|
"expiry_date": body.expiry_date,
|
|
},
|
|
"message": "许可证生成成功"
|
|
}
|
|
|
|
@router.get("/licenses/{license_id}", summary="许可证详情")
|
|
async def get_license(
|
|
license_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, """
|
|
SELECT l.*, d.name as distribution_name
|
|
FROM distribution_licenses l
|
|
LEFT JOIN distributions d ON l.distribution_id = d.id
|
|
WHERE l.id = :id
|
|
""", {"id": license_id})
|
|
row = rows.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="许可证不存在")
|
|
|
|
cols = list(rows.keys())
|
|
lic = _row_to_dict(row, cols)
|
|
for key in ["features", "metadata_json"]:
|
|
if lic.get(key) and isinstance(lic[key], str):
|
|
try:
|
|
lic[key] = json.loads(lic[key])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
for key in ["created_at", "updated_at", "first_used_at", "last_used_at"]:
|
|
lic[key] = _serialize_dt(lic[key])
|
|
if lic.get("expiry_date"):
|
|
lic["expiry_date"] = str(lic["expiry_date"])
|
|
|
|
return {"code": 0, "data": lic}
|
|
|
|
@router.post("/licenses/{license_id}/revoke", summary="吊销许可证")
|
|
async def revoke_license(
|
|
license_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, "SELECT id, status FROM distribution_licenses WHERE id = :id", {"id": license_id})
|
|
row = rows.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="许可证不存在")
|
|
|
|
_execute_raw(db, "UPDATE distribution_licenses SET status='revoked', updated_at=NOW() WHERE id = :id", {"id": license_id})
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "许可证已吊销"}
|
|
|
|
@router.post("/licenses/validate", summary="验证许可证")
|
|
async def validate_license(
|
|
body: LicenseValidate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""公开 API:验证许可证有效性(不需要 admin 权限)"""
|
|
rows = _execute_raw(db,
|
|
"SELECT * FROM distribution_licenses WHERE license_key = :key AND status='active'",
|
|
{"key": body.license_key}
|
|
)
|
|
row = rows.fetchone()
|
|
if not row:
|
|
return {"code": 1, "data": {"valid": False, "reason": "license_not_found"}, "message": "许可证不存在或已失效"}
|
|
|
|
cols = list(rows.keys())
|
|
lic = _row_to_dict(row, cols)
|
|
|
|
# Check expiry
|
|
if lic.get("expiry_date"):
|
|
expiry = lic["expiry_date"]
|
|
if isinstance(expiry, str):
|
|
expiry = datetime.strptime(expiry, "%Y-%m-%d").date()
|
|
if expiry < datetime.now().date():
|
|
return {"code": 1, "data": {"valid": False, "reason": "expired"}, "message": "许可证已过期"}
|
|
|
|
# Check usage limit
|
|
if lic.get("max_executions") and lic.get("usage_count", 0) >= lic["max_executions"]:
|
|
return {"code": 1, "data": {"valid": False, "reason": "usage_exceeded"}, "message": "许可证用量已超限"}
|
|
|
|
features = lic.get("features")
|
|
if isinstance(features, str):
|
|
try:
|
|
features = json.loads(features)
|
|
except json.JSONDecodeError:
|
|
features = []
|
|
|
|
# Update usage
|
|
_execute_raw(db, """
|
|
UPDATE distribution_licenses
|
|
SET usage_count = usage_count + 1,
|
|
last_used_at = NOW(),
|
|
first_used_at = COALESCE(first_used_at, NOW())
|
|
WHERE id = :id
|
|
""", {"id": lic["id"]})
|
|
db.commit()
|
|
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"valid": True,
|
|
"org_name": lic.get("org_name"),
|
|
"features": features,
|
|
"max_users": lic.get("max_users"),
|
|
"max_executions": lic.get("max_executions"),
|
|
"usage_count": lic.get("usage_count", 0) + 1,
|
|
"expiry_date": str(lic["expiry_date"]) if lic.get("expiry_date") else None,
|
|
}
|
|
}
|
|
|
|
|
|
@router.get("/{distro_id}", summary="发行版详情")
|
|
async def get_distribution(
|
|
distro_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id})
|
|
row = rows.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="发行版不存在")
|
|
|
|
cols = list(rows.keys())
|
|
d = _row_to_dict(row, cols)
|
|
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
|
|
if d.get(key) and isinstance(d[key], str):
|
|
try:
|
|
d[key] = json.loads(d[key])
|
|
except (json.JSONDecodeError, TypeError):
|
|
pass
|
|
for key in ["created_at", "updated_at"]:
|
|
d[key] = _serialize_dt(d[key])
|
|
|
|
# Get associated licenses count
|
|
lic_rows = _execute_raw(db, "SELECT COUNT(*) as cnt FROM distribution_licenses WHERE distribution_id = :did", {"did": distro_id})
|
|
d["license_count"] = lic_rows.fetchone()[0]
|
|
|
|
return {"code": 0, "data": d}
|
|
|
|
@router.put("/{distro_id}", summary="更新发行版")
|
|
async def update_distribution(
|
|
distro_id: str,
|
|
body: DistroUpdate,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id})
|
|
if not rows.fetchone():
|
|
raise HTTPException(status_code=404, detail="发行版不存在")
|
|
|
|
updates = []
|
|
params = {"id": distro_id}
|
|
for field, value in body.dict(exclude_unset=True).items():
|
|
if field in ("mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features"):
|
|
updates.append(f"{field} = :{field}")
|
|
params[field] = json.dumps(value) if value else None
|
|
else:
|
|
updates.append(f"{field} = :{field}")
|
|
params[field] = value
|
|
|
|
if updates:
|
|
updates.append("updated_at = NOW()")
|
|
_execute_raw(db, f"UPDATE distributions SET {', '.join(updates)} WHERE id = :id", params)
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "更新成功"}
|
|
|
|
@router.delete("/{distro_id}", summary="删除发行版")
|
|
async def delete_distribution(
|
|
distro_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
rows = _execute_raw(db, "SELECT id FROM distributions WHERE id = :id", {"id": distro_id})
|
|
if not rows.fetchone():
|
|
raise HTTPException(status_code=404, detail="发行版不存在")
|
|
|
|
# Delete associated licenses
|
|
_execute_raw(db, "DELETE FROM distribution_licenses WHERE distribution_id = :id", {"id": distro_id})
|
|
_execute_raw(db, "DELETE FROM distributions WHERE id = :id", {"id": distro_id})
|
|
db.commit()
|
|
|
|
return {"code": 0, "message": "删除成功"}
|
|
|
|
# ==================== Build & Export ====================
|
|
|
|
@router.post("/{distro_id}/build", summary="构建配置包")
|
|
async def build_distribution(
|
|
distro_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""构建发行版配置包 ZIP (YAML config + recipes + extensions + license template)"""
|
|
rows = _execute_raw(db, "SELECT * FROM distributions WHERE id = :id", {"id": distro_id})
|
|
row = rows.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="发行版不存在")
|
|
|
|
cols = list(rows.keys())
|
|
d = _row_to_dict(row, cols)
|
|
|
|
# Parse JSON fields
|
|
for key in ["mcp_extensions", "workflow_ids", "knowledge_base_ids", "agent_ids", "features", "config_json"]:
|
|
if d.get(key) and isinstance(d[key], str):
|
|
try:
|
|
d[key] = json.loads(d[key])
|
|
except (json.JSONDecodeError, TypeError):
|
|
d[key] = [] if "ids" in key else ({} if key == "config_json" else [])
|
|
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
|
|
# 1. init-config.yaml
|
|
init_config = {
|
|
"GOOSE_PROVIDER": d.get("provider", "siliconflow"),
|
|
"GOOSE_MODEL": d.get("model", "deepseek-ai/DeepSeek-V4-Flash"),
|
|
"PLATFORM_API_URL": "https://your-platform.com/api",
|
|
"LICENSE_KEY": "YOUR_LICENSE_KEY_HERE",
|
|
}
|
|
zf.writestr("init-config.yaml", yaml.dump(init_config, allow_unicode=True, default_flow_style=False))
|
|
|
|
# 2. config.yaml (MCP extensions)
|
|
config = {
|
|
"extensions": {},
|
|
}
|
|
for ext in (d.get("mcp_extensions") or []):
|
|
ext_id = ext.get("id", str(uuid.uuid4().hex[:8]))
|
|
config["extensions"][ext_id] = {
|
|
"name": ext.get("name", "unnamed"),
|
|
"cmd": ext.get("cmd", ""),
|
|
"args": ext.get("args", []),
|
|
"enabled": True,
|
|
"type": ext.get("type", "stdio"),
|
|
"timeout": ext.get("timeout", 30000),
|
|
}
|
|
zf.writestr("config.yaml", yaml.dump(config, allow_unicode=True, default_flow_style=False))
|
|
|
|
# 3. Recipes (workflow exports)
|
|
wf_ids = d.get("workflow_ids") or []
|
|
for wf_id in wf_ids:
|
|
wf_rows = _execute_raw(db, "SELECT * FROM agent_workflows WHERE id = :id", {"id": wf_id})
|
|
wf_row = wf_rows.fetchone()
|
|
if wf_row:
|
|
wf_cols = list(wf_rows.keys())
|
|
wf = _row_to_dict(wf_row, wf_cols)
|
|
recipe = {
|
|
"name": wf.get("name", wf_id),
|
|
"description": wf.get("description", ""),
|
|
"steps": [],
|
|
}
|
|
nodes = wf.get("nodes")
|
|
if isinstance(nodes, str):
|
|
try:
|
|
nodes = json.loads(nodes)
|
|
except json.JSONDecodeError:
|
|
nodes = []
|
|
for node in (nodes or []):
|
|
recipe["steps"].append({
|
|
"type": node.get("type", ""),
|
|
"label": node.get("data", {}).get("label", ""),
|
|
"config": node.get("data", {}).get("config", {}),
|
|
})
|
|
zf.writestr(f"recipes/{wf_id}.yaml", yaml.dump(recipe, allow_unicode=True, default_flow_style=False))
|
|
|
|
# 4. Branding
|
|
zf.writestr("branding/system-prompt.md", d.get("system_prompt", "# AI Assistant\n\nYou are a helpful AI assistant."))
|
|
branding = {
|
|
"app_name": d.get("brand_name", d.get("name", "AI Assistant")),
|
|
"brand_color": d.get("brand_color", "#6366f1"),
|
|
"version": d.get("version", "1.0.0"),
|
|
}
|
|
zf.writestr("branding/manifest.yaml", yaml.dump(branding, allow_unicode=True))
|
|
|
|
# 5. License template
|
|
license_template = {
|
|
"license_key": "TO_BE_GENERATED",
|
|
"org_name": "YOUR_ORGANIZATION",
|
|
"expiry": None,
|
|
"features": d.get("features", ["chat"]),
|
|
}
|
|
zf.writestr("license.template.yaml", yaml.dump(license_template, allow_unicode=True))
|
|
|
|
# 6. install.sh
|
|
install_script = """#!/bin/bash
|
|
# Distribution Installation Script
|
|
echo "Installing Distribution: {name}"
|
|
echo "Version: {version}"
|
|
echo ""
|
|
echo "1. Copy init-config.yaml to ~/.config/goose/"
|
|
cp init-config.yaml ~/.config/goose/init-config.yaml
|
|
echo "2. Copy config.yaml"
|
|
cp config.yaml ~/.config/goose/config.yaml
|
|
echo "3. Copy recipes"
|
|
mkdir -p ~/.config/goose/recipes/
|
|
cp -r recipes/ ~/.config/goose/recipes/
|
|
echo "4. Copy branding"
|
|
mkdir -p ~/.config/goose/branding/
|
|
cp -r branding/ ~/.config/goose/branding/
|
|
echo "Installation complete!"
|
|
""".format(name=d.get("name", "Unnamed"), version=d.get("version", "1.0.0"))
|
|
zf.writestr("install.sh", install_script)
|
|
|
|
# 7. README
|
|
readme = f"""# {d.get('brand_name', d.get('name', 'Distribution'))}
|
|
|
|
## Description
|
|
{d.get('description', 'No description')}
|
|
|
|
## Included Features
|
|
""" + "\n".join([f"- {f}" for f in (d.get("features") or ["chat"])]) + f"""
|
|
|
|
## Version
|
|
{d.get('version', '1.0.0')}
|
|
|
|
## Installation
|
|
Run \"bash install.sh\" to install.
|
|
|
|
## License
|
|
A valid license key is required. Contact your administrator for details.
|
|
"""
|
|
zf.writestr("README.md", readme)
|
|
|
|
# Update download count and size
|
|
size = buf.tell()
|
|
_execute_raw(db, "UPDATE distributions SET download_count = download_count + 1, size_bytes = :size WHERE id = :id",
|
|
{"size": size, "id": distro_id})
|
|
db.commit()
|
|
|
|
buf.seek(0)
|
|
|
|
filename = f"{d.get('name', 'distro').replace(' ', '_')}-{d.get('version', '1.0.0')}.zip"
|
|
return StreamingResponse(
|
|
buf,
|
|
media_type="application/zip",
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
|
)
|
|
|
|
@router.get("/{distro_id}/download", summary="下载配置包")
|
|
async def download_distribution(
|
|
distro_id: str,
|
|
admin_user=Depends(get_current_admin),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""直接下载已生成的配置包"""
|
|
return await build_distribution(distro_id, admin_user, db)
|
|
|
|
# ==================== LLM Proxy API ====================
|
|
|
|
@router.post("/llm-proxy/{api_key:path}", summary="LLM API 代理")
|
|
async def llm_proxy(
|
|
api_key: str,
|
|
admin_user=Depends(get_current_admin),
|
|
):
|
|
"""LLM 代理端点状态检查"""
|
|
return {
|
|
"code": 0,
|
|
"data": {
|
|
"status": "ready",
|
|
"providers": ["siliconflow", "deepseek"],
|
|
"message": "LLM Proxy is operational. Use POST /admin/distributions/llm-proxy/chat to call models."
|
|
}
|
|
}
|