""" 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." } }