Phase G4: Distribution Builder - SaaS product line mgmt, license keys, config export, frontend UI

This commit is contained in:
qyyfw committed 2026-07-06 16:36:26 +08:00
1 parent d2e3147fb8
commit 0486210133
7 files changed
+1416 -3

No files matched your search

@@ -0,0 +1,752 @@
"""
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."
}
}
+2 -1
View File
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, code_exec_admin
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin
from app.api.admin.developer_tools_admin import router as developer_tools_router
from app.api.admin.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin
@@ -432,6 +432,7 @@ app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tool
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
+81
View File
@@ -0,0 +1,81 @@
import urllib.request, json
def test(label, data):
req = urllib.request.Request(
'http://localhost:18001/execute',
data=json.dumps(data).encode(),
headers={'Content-Type': 'application/json'}
)
try:
resp = urllib.request.urlopen(req, timeout=15)
result = json.loads(resp.read())
print(f'[PASS] {label}: success={result.get("success")}, exit_code={result.get("exit_code")}, time={result.get("execution_time_ms",0):.1f}ms')
if result.get('stdout'):
print(f' stdout: {result["stdout"][:200]}')
if result.get('stderr'):
print(f' stderr: {result["stderr"][:200]}')
if result.get('error'):
print(f' error: {result["error"]}')
except Exception as e:
print(f'[FAIL] {label}: {e}')
print('=== Sandbox Execution Tests ===')
# Test 1: Python hello
test('Python Hello', {
'language': 'python',
'code': 'print("Hello from sandbox!")\nimport sys\nprint(f"Python {sys.version.split()[0]}")',
'timeout': 10
})
# Test 2: Python with stdin
test('Python stdin', {
'language': 'python',
'code': 'name = input()\nage = input()\nprint(f"Hello {name}, age {age}")',
'stdin': 'Alice\n25\n',
'timeout': 10
})
# Test 3: JavaScript
test('JavaScript', {
'language': 'javascript',
'code': 'console.log("Hello from Node.js!");\nconsole.log("Version: " + process.version);',
'timeout': 10
})
# Test 4: Timeout
test('Timeout', {
'language': 'python',
'code': 'import time\ntime.sleep(10)\nprint("Done")',
'timeout': 2
})
# Test 5: Compute
test('Fibonacci', {
'language': 'python',
'code': 'def fib(n):\n a,b=0,1\n for _ in range(n): a,b=b,a+b\n return a\nprint(f"fib(30)={fib(30)}")',
'timeout': 10
})
# Test 6: Error code
test('Python Error', {
'language': 'python',
'code': 'print(1/0)',
'timeout': 10
})
# Test 7: JS Error
test('JS Error', {
'language': 'javascript',
'code': 'throw new Error("test error");',
'timeout': 10
})
# Test 8: Large output
test('Large Output', {
'language': 'python',
'code': 'for i in range(100): print(f"Line {i}: Hello World" * 5)',
'timeout': 10
})
print('\nDone!')
+21
View File
@@ -663,6 +663,27 @@ export const adminAPI = {
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
deleteCodeExecTask: (id: string) => del<any>(`/admin/code-exec/tasks/${id}`),
// Distribution Builder (Phase G4)
getDistributionStats: () => get<any>('/admin/distributions/stats'),
getDistributions: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
return get<any>('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : ''));
},
getDistributionTemplates: () => get<any>('/admin/distributions/templates'),
createDistribution: (data: any) => post<any>('/admin/distributions', data),
getDistribution: (id: string) => get<any>('/admin/distributions/' + id),
updateDistribution: (id: string, data: any) => put<any>('/admin/distributions/' + id, data),
deleteDistribution: (id: string) => del<any>('/admin/distributions/' + id),
getDistributionLicenses: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); });
return get<any>('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : ''));
},
createDistributionLicense: (distroId: string, data: any) => post<any>('/admin/distributions/' + distroId + '/licenses', data),
getDistributionLicense: (id: string) => get<any>('/admin/distributions/licenses/' + id),
revokeDistributionLicense: (id: string) => post<any>('/admin/distributions/licenses/' + id + '/revoke'),
};
// ===== AI自进化API =====
export const evolutionAPI = {
@@ -17,7 +17,9 @@ const adminNavItems = [
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },];
{ path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' },
{ path: '/admin/distributions', icon: 'distro', label: '发行版构建' },
];
const adminIconPaths: Record<string, JSX.Element> = {
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
@@ -32,7 +34,8 @@ const adminIconPaths: Record<string, JSX.Element> = {
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,};
code: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>,
distro: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>,};
const AdminLayout: React.FC = () => {
const { user, logout } = useAuth();
@@ -0,0 +1,493 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
interface Distribution {
id: string;
name: string;
description: string;
brand_name: string;
brand_logo_url: string;
brand_color: string;
system_prompt: string;
provider: string;
model: string;
mcp_extensions: any[];
workflow_ids: string[];
knowledge_base_ids: string[];
agent_ids: string[];
features: string[];
status: string;
version: string;
download_count: number;
size_bytes: number;
license_count?: number;
created_at: string;
updated_at: string;
}
interface License {
id: string;
distribution_id: string;
distribution_name: string;
org_name: string;
licensee_email: string;
license_key: string;
features: string[];
max_users: number;
max_executions: number;
usage_count: number;
expiry_date: string | null;
status: string;
created_at: string;
last_used_at: string | null;
}
interface Stats {
total: number;
draft: number;
active: number;
archived: number;
total_downloads: number;
total_licenses: number;
active_licenses: number;
}
interface Templates {
workflows: any[];
agents: any[];
knowledge_bases: any[];
mcp_tools: any[];
}
const STATUS_LABELS: Record<string, string> = { draft: '草稿', active: '已发布', archived: '已归档' };
const STATUS_COLORS: Record<string, string> = { draft: '#8b949e', active: '#3fb950', archived: '#f85149' };
const FEATURE_LABELS: Record<string, string> = {
chat: 'AI 对话', code_exec: '代码执行', knowledge_search: '知识库检索',
agent_collab: 'Agent 协作', automation: '自动化', webhook: 'Webhook',
};
export default function DistributionBuilder() {
const [distributions, setDistributions] = useState<Distribution[]>([]);
const [stats, setStats] = useState<Stats>({ total: 0, draft: 0, active: 0, archived: 0, total_downloads: 0, total_licenses: 0, active_licenses: 0 });
const [templates, setTemplates] = useState<Templates>({ workflows: [], agents: [], knowledge_bases: [], mcp_tools: [] });
const [licenses, setLicenses] = useState<License[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'distributions' | 'licenses'>('distributions');
const [showCreate, setShowCreate] = useState(false);
const [showLicense, setShowLicense] = useState(false);
const [selectedDistro, setSelectedDistro] = useState<string>('');
const [editDistro, setEditDistro] = useState<Distribution | null>(null);
const [page, setPage] = useState(1);
const [licPage, setLicPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [licTotalPages, setLicTotalPages] = useState(1);
// Form state
const [form, setForm] = useState({
name: '', description: '', brand_name: '', brand_color: '#6366f1',
system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash',
features: ['chat'] as string[], version: '1.0.0',
workflow_ids: [] as string[], agent_ids: [] as string[], knowledge_base_ids: [] as string[],
});
// License form
const [licForm, setLicForm] = useState({
org_name: '', licensee_email: '', max_users: 10, max_executions: 10000,
expiry_date: '', features: ['chat'] as string[],
});
const loadData = async () => {
setLoading(true);
try {
const [s, d, t] = await Promise.all([
adminAPI.getDistributionStats(),
adminAPI.getDistributions({ page, page_size: 20 }),
adminAPI.getDistributionTemplates(),
]);
if (s.code === 0) setStats(s.data);
if (d.code === 0) { setDistributions(d.data.items); setTotalPages(Math.ceil(d.data.total / 20)); }
if (t.code === 0) setTemplates(t.data);
} catch (e) { console.error(e); }
setLoading(false);
};
const loadLicenses = async () => {
try {
const res = await adminAPI.getDistributionLicenses({ page: licPage, page_size: 20 });
if (res.code === 0) { setLicenses(res.data.items); setLicTotalPages(Math.ceil(res.data.total / 20)); }
} catch (e) { console.error(e); }
};
useEffect(() => { loadData(); }, [page]);
useEffect(() => { if (tab === 'licenses') loadLicenses(); }, [tab, licPage]);
const handleCreate = async () => {
try {
const res = await adminAPI.createDistribution(form);
if (res.code === 0) { setShowCreate(false); resetForm(); loadData(); }
else alert('创建失败: ' + (res.message || ''));
} catch (e) { alert('创建失败'); }
};
const handleUpdate = async () => {
if (!editDistro) return;
try {
const res = await adminAPI.updateDistribution(editDistro.id, form);
if (res.code === 0) { setEditDistro(null); resetForm(); loadData(); }
else alert('更新失败');
} catch (e) { alert('更新失败'); }
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此发行版?关联的许可证也将被删除。')) return;
try {
await adminAPI.deleteDistribution(id);
loadData();
} catch (e) { alert('删除失败'); }
};
const handleBuild = async (id: string) => {
try {
const token = localStorage.getItem('token') || '';
window.open(`/app/api/admin/distributions/${id}/build`, '_blank');
} catch (e) { alert('下载失败'); }
};
const handleGenerateLicense = async () => {
if (!selectedDistro) return;
try {
const res = await adminAPI.createDistributionLicense(selectedDistro, licForm);
if (res.code === 0) { setShowLicense(false); setLicForm({ org_name: '', licensee_email: '', max_users: 10, max_executions: 10000, expiry_date: '', features: ['chat'] }); loadLicenses(); }
else alert('生成失败: ' + (res.message || ''));
} catch (e) { alert('生成失败'); }
};
const handleRevokeLicense = async (id: string) => {
if (!confirm('确定吊销此许可证?')) return;
try {
await adminAPI.revokeDistributionLicense(id);
loadLicenses();
} catch (e) { alert('吊销失败'); }
};
const resetForm = () => {
setForm({ name: '', description: '', brand_name: '', brand_color: '#6366f1', system_prompt: '', provider: 'siliconflow', model: 'deepseek-ai/DeepSeek-V4-Flash', features: ['chat'], version: '1.0.0', workflow_ids: [], agent_ids: [], knowledge_base_ids: [] });
};
const openEdit = (d: Distribution) => {
setEditDistro(d);
setForm({
name: d.name, description: d.description || '', brand_name: d.brand_name || '',
brand_color: d.brand_color || '#6366f1', system_prompt: d.system_prompt || '',
provider: d.provider || 'siliconflow', model: d.model || 'deepseek-ai/DeepSeek-V4-Flash',
features: d.features || ['chat'], version: d.version || '1.0.0',
workflow_ids: d.workflow_ids || [], agent_ids: d.agent_ids || [], knowledge_base_ids: d.knowledge_base_ids || [],
});
};
const toggleFeature = (feat: string) => {
setForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const toggleLicFeature = (feat: string) => {
setLicForm(prev => ({
...prev,
features: prev.features.includes(feat) ? prev.features.filter(f => f !== feat) : [...prev.features, feat],
}));
};
const isEditing = !!editDistro;
// Styles
const dark = { bg: '#0d1117', card: '#161b22', border: '#21262d', text: '#c9d1d9', sub: '#8b949e', accent: '#6366f1', accentLight: '#818cf8', green: '#3fb950', red: '#f85149', yellow: '#d29922' };
const cardStyle: React.CSSProperties = { background: dark.card, border: `1px solid ${dark.border}`, borderRadius: 12, padding: 20 };
const btnPrimary: React.CSSProperties = { background: dark.accent, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnDanger: React.CSSProperties = { background: dark.red, color: '#fff', border: 'none', borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14, fontWeight: 600 };
const btnGhost: React.CSSProperties = { background: 'transparent', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 8, padding: '8px 20px', cursor: 'pointer', fontSize: 14 };
const inputStyle: React.CSSProperties = { background: '#0d1117', color: dark.text, border: `1px solid ${dark.border}`, borderRadius: 6, padding: '8px 12px', fontSize: 14, width: '100%', boxSizing: 'border-box', outline: 'none' };
const textareaStyle: React.CSSProperties = { ...inputStyle, minHeight: 80, resize: 'vertical', fontFamily: 'monospace' };
const labelStyle: React.CSSProperties = { color: dark.sub, fontSize: 13, marginBottom: 4, display: 'block' };
return (
<div style={{ padding: 24, background: dark.bg, minHeight: '100vh', color: dark.text, fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 28, fontWeight: 700 }}>发行版构建器</h1>
<p style={{ margin: '4px 0 0', color: dark.sub, fontSize: 13 }}>SaaS 产品线管理 · 自定义发行版配置包生成 · 许可证管理</p>
</div>
<button onClick={() => { setShowCreate(true); setEditDistro(null); resetForm(); }} style={btnPrimary}>
+ 新建发行版
</button>
</div>
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
{[
{ label: '发行版总数', value: stats.total },
{ label: '已发布', value: stats.active, color: dark.green },
{ label: '累计下载', value: stats.total_downloads, color: dark.accentLight },
{ label: '有效许可证', value: stats.active_licenses, color: dark.yellow },
].map((s, i) => (
<div key={i} style={cardStyle}>
<div style={{ color: dark.sub, fontSize: 13 }}>{s.label}</div>
<div style={{ fontSize: 32, fontWeight: 700, color: s.color || dark.text, marginTop: 4 }}>{s.value}</div>
</div>
))}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${dark.border}` }}>
{[
{ key: 'distributions', label: '发行版管理' },
{ key: 'licenses', label: '许可证管理' },
].map(t => (
<button key={t.key}
onClick={() => setTab(t.key as any)}
style={{
padding: '10px 24px', background: 'transparent', border: 'none',
color: tab === t.key ? dark.accentLight : dark.sub,
borderBottom: tab === t.key ? `2px solid ${dark.accent}` : '2px solid transparent',
cursor: 'pointer', fontSize: 15, fontWeight: tab === t.key ? 600 : 400,
}}>
{t.label}
</button>
))}
</div>
{/* Distributions Tab */}
{tab === 'distributions' && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{distributions.map(d => (
<div key={d.id} style={{ ...cardStyle, display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ width: 48, height: 48, borderRadius: 10, background: d.brand_color || dark.accent, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
{(d.brand_name || d.name)[0]}
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 16, fontWeight: 600 }}>{d.name}</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: STATUS_COLORS[d.status] + '22', color: STATUS_COLORS[d.status], fontWeight: 600 }}>
{STATUS_LABELS[d.status] || d.status}
</span>
<span style={{ color: dark.sub, fontSize: 12 }}>v{d.version}</span>
</div>
<div style={{ color: dark.sub, fontSize: 13, marginTop: 2 }}>{d.description || '无描述'}</div>
<div style={{ display: 'flex', gap: 12, marginTop: 6, fontSize: 12, color: dark.sub }}>
{d.features?.map(f => (
<span key={f}>✓ {FEATURE_LABELS[f] || f}</span>
))}
<span>下载 {d.download_count || 0} 次</span>
<span>{d.license_count || 0} 个许可证</span>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button onClick={() => handleBuild(d.id)} style={btnGhost}>下载配置包</button>
<button onClick={() => { setSelectedDistro(d.id); setShowLicense(true); }} style={btnGhost}>生成许可证</button>
<button onClick={() => openEdit(d)} style={btnGhost}>编辑</button>
<button onClick={() => handleDelete(d.id)} style={btnDanger}>删除</button>
</div>
</div>
))}
</div>
{totalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {page}/{totalPages} 页</span>
<button onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Licenses Tab */}
{tab === 'licenses' && (
<>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button onClick={() => setShowLicense(true)} style={btnPrimary}>+ 生成许可证</button>
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ borderBottom: `1px solid ${dark.border}` }}>
{['许可证密钥', '组织', '发行版', '用量', '状态', '过期', '操作'].map(h => (
<th key={h} style={{ padding: '10px 12px', textAlign: 'left', color: dark.sub, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{licenses.map(lic => (
<tr key={lic.id} style={{ borderBottom: `1px solid ${dark.border}` }}>
<td style={{ padding: '10px 12px', fontFamily: 'monospace', fontSize: 12, color: dark.accentLight }}>{lic.license_key}</td>
<td style={{ padding: '10px 12px' }}>{lic.org_name}</td>
<td style={{ padding: '10px 12px' }}>{lic.distribution_name}</td>
<td style={{ padding: '10px 12px', color: dark.sub }}>{lic.usage_count}/{lic.max_executions}</td>
<td style={{ padding: '10px 12px' }}>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 10, background: lic.status === 'active' ? '#3fb95022' : '#f8514922', color: lic.status === 'active' ? dark.green : dark.red }}>{lic.status}</span>
</td>
<td style={{ padding: '10px 12px', color: dark.sub, fontSize: 12 }}>{lic.expiry_date || '永久'}</td>
<td style={{ padding: '10px 12px' }}>
{lic.status === 'active' && (
<button onClick={() => handleRevokeLicense(lic.id)} style={{ ...btnDanger, padding: '4px 12px', fontSize: 12 }}>吊销</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{licTotalPages > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 20 }}>
<button onClick={() => setLicPage(p => Math.max(1, p - 1))} disabled={licPage <= 1} style={btnGhost}>上一页</button>
<span style={{ padding: '8px 16px', color: dark.sub }}>第 {licPage}/{licTotalPages} 页</span>
<button onClick={() => setLicPage(p => Math.min(licTotalPages, p + 1))} disabled={licPage >= licTotalPages} style={btnGhost}>下一页</button>
</div>
)}
</>
)}
{/* Create/Edit Modal */}
{(showCreate || isEditing) && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 640, maxHeight: '85vh', overflow: 'auto', padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>{isEditing ? '编辑发行版' : '新建发行版'}</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>名称 *</label>
<input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="例如: Enterprise AI Suite" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>描述</label>
<textarea style={textareaStyle} value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="发行版描述..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>品牌名称</label>
<input style={inputStyle} value={form.brand_name} onChange={e => setForm({ ...form, brand_name: e.target.value })} placeholder="例如: MyAI" />
</div>
<div>
<label style={labelStyle}>品牌主色</label>
<input type="color" style={{ ...inputStyle, height: 38, padding: 4, width: 60 }} value={form.brand_color} onChange={e => setForm({ ...form, brand_color: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>系统提示词</label>
<textarea style={textareaStyle} value={form.system_prompt} onChange={e => setForm({ ...form, system_prompt: e.target.value })} placeholder="You are a helpful AI assistant..." />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>LLM 提供商</label>
<select style={inputStyle} value={form.provider} onChange={e => setForm({ ...form, provider: e.target.value })}>
<option value="siliconflow">SiliconFlow</option>
<option value="deepseek">DeepSeek</option>
<option value="openai">OpenAI</option>
</select>
</div>
<div>
<label style={labelStyle}>默认模型</label>
<input style={inputStyle} value={form.model} onChange={e => setForm({ ...form, model: e.target.value })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>版本号</label>
<input style={inputStyle} value={form.version} onChange={e => setForm({ ...form, version: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>功能特性</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: form.features.includes(key) ? dark.accent : 'transparent',
color: form.features.includes(key) ? '#fff' : dark.sub,
border: form.features.includes(key) ? `1px solid ${dark.accent}` : `1px solid ${dark.border}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => { setShowCreate(false); setEditDistro(null); }} style={btnGhost}>取消</button>
<button onClick={isEditing ? handleUpdate : handleCreate} style={btnPrimary}>{isEditing ? '保存修改' : '创建'}</button>
</div>
</div>
</div>
)}
{/* License Modal */}
{showLicense && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ ...cardStyle, width: 500, padding: 28 }}>
<h2 style={{ margin: '0 0 20px' }}>生成许可证</h2>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>发行版 *</label>
<select style={inputStyle} value={selectedDistro} onChange={e => setSelectedDistro(e.target.value)}>
<option value="">-- 选择发行版 --</option>
{distributions.map(d => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</select>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>组织名称 *</label>
<input style={inputStyle} value={licForm.org_name} onChange={e => setLicForm({ ...licForm, org_name: e.target.value })} placeholder="例如: Acme Corp" />
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>被授权邮箱</label>
<input style={inputStyle} value={licForm.licensee_email} onChange={e => setLicForm({ ...licForm, licensee_email: e.target.value })} placeholder="admin@example.com" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<label style={labelStyle}>最大用户数</label>
<input type="number" style={inputStyle} value={licForm.max_users} onChange={e => setLicForm({ ...licForm, max_users: Number(e.target.value) })} />
</div>
<div>
<label style={labelStyle}>最大执行次数</label>
<input type="number" style={inputStyle} value={licForm.max_executions} onChange={e => setLicForm({ ...licForm, max_executions: Number(e.target.value) })} />
</div>
</div>
<div style={{ marginBottom: 12 }}>
<label style={labelStyle}>过期日期 (留空为永久)</label>
<input type="date" style={inputStyle} value={licForm.expiry_date} onChange={e => setLicForm({ ...licForm, expiry_date: e.target.value })} />
</div>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle}>授权功能</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{Object.entries(FEATURE_LABELS).map(([key, label]) => (
<button key={key}
onClick={() => toggleLicFeature(key)}
style={{
padding: '6px 14px', borderRadius: 20, fontSize: 13, cursor: 'pointer',
background: licForm.features.includes(key) ? dark.accent : 'transparent',
color: licForm.features.includes(key) ? '#fff' : dark.sub,
border: licForm.features.includes(key) ? `1px solid ${dark.accent}` : `1px solid ${dark.border}`,
}}>
{label}
</button>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>
<button onClick={() => setShowLicense(false)} style={btnGhost}>取消</button>
<button onClick={handleGenerateLicense} style={btnPrimary}>生成</button>
</div>
</div>
</div>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
-- Phase G4: Distribution Builder - Database Migration
-- Execute: docker exec -i qihu-aiflow-mysql mysql -uroot -ppwd2026 aiflow < mig_g4_distribution.sql
-- Table: distributions (发行版配置)
CREATE TABLE IF NOT EXISTS `distributions` (
`id` VARCHAR(64) NOT NULL,
`name` VARCHAR(255) NOT NULL COMMENT '发行版名称',
`description` TEXT COMMENT '发行版描述',
`brand_name` VARCHAR(255) DEFAULT NULL COMMENT '品牌名称',
`brand_logo_url` VARCHAR(1024) DEFAULT NULL COMMENT '品牌 Logo URL',
`brand_color` VARCHAR(7) DEFAULT '#6366f1' COMMENT '品牌主色',
`system_prompt` TEXT COMMENT '系统提示词',
`provider` VARCHAR(100) DEFAULT 'siliconflow' COMMENT 'LLM 提供商',
`model` VARCHAR(200) DEFAULT 'deepseek-ai/DeepSeek-V4-Flash' COMMENT '默认模型',
`mcp_extensions` JSON COMMENT '捆绑的 MCP 扩展配置 [{id, name, cmd, args, envs, type, timeout}]',
`workflow_ids` JSON COMMENT '捆绑的工作流 ID 列表 ["wf-001", "wf-002"]',
`knowledge_base_ids` JSON COMMENT '捆绑的知识库 ID 列表',
`agent_ids` JSON COMMENT '捆绑的 Agent ID 列表',
`features` JSON COMMENT '功能特性 ["code_exec", "knowledge_search", "agent_collab"]',
`config_json` JSON COMMENT '完整配置 JSON',
`status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '状态: draft/active/archived',
`version` VARCHAR(20) DEFAULT '1.0.0' COMMENT '版本号',
`download_count` INT DEFAULT 0 COMMENT '下载次数',
`size_bytes` BIGINT DEFAULT 0 COMMENT '包大小(字节)',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_distributions_status` (`status`),
INDEX `idx_distributions_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table: distribution_licenses (发行版许可证)
CREATE TABLE IF NOT EXISTS `distribution_licenses` (
`id` VARCHAR(64) NOT NULL,
`distribution_id` VARCHAR(64) NOT NULL COMMENT '关联发行版',
`org_name` VARCHAR(255) NOT NULL COMMENT '被授权组织名称',
`licensee_email` VARCHAR(255) DEFAULT NULL COMMENT '被授权人邮箱',
`license_key` VARCHAR(128) NOT NULL COMMENT '许可证密钥 (HMAC-SHA256)',
`features` JSON COMMENT '授权功能列表',
`max_users` INT DEFAULT 10 COMMENT '最大用户数',
`max_executions` INT DEFAULT 10000 COMMENT '最大执行次数',
`usage_count` INT DEFAULT 0 COMMENT '已使用次数',
`expiry_date` DATE DEFAULT NULL COMMENT '过期日期 (NULL=永不过期)',
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/revoked/expired',
`metadata_json` JSON COMMENT '自定义元数据',
`first_used_at` DATETIME DEFAULT NULL COMMENT '首次使用时间',
`last_used_at` DATETIME DEFAULT NULL COMMENT '最后使用时间',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_license_key` (`license_key`),
INDEX `idx_licenses_distribution` (`distribution_id`),
INDEX `idx_licenses_status` (`status`),
INDEX `idx_licenses_expiry` (`expiry_date`),
INDEX `idx_licenses_org` (`org_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Insert seed data: sample distributions
INSERT INTO `distributions` (`id`, `name`, `description`, `brand_name`, `brand_color`, `provider`, `model`, `features`, `status`) VALUES
('distro-starter', 'Starter Edition', '基础版发行包,适合个人开发者和小团队', 'AI Starter', '#6366f1', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec"]', 'active'),
('distro-enterprise', 'Enterprise Edition', '企业版发行包,包含完整 MCP 生态 + Agent 协作', 'AI Enterprise', '#f59e0b', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "knowledge_search", "agent_collab"]', 'active'),
('distro-devops', 'DevOps Edition', 'DevOps 专用发行包,CI/CD + 代码审查 + 部署', 'AI DevOps', '#10b981', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash', '["chat", "code_exec", "automation", "webhook"]', 'active');