Phase G4: Distribution Builder - SaaS product line mgmt, license keys, config export, frontend UI
This commit is contained in:
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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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.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.mcp_market_admin import router as mcp_market_router
|
||||||
from app.api.admin import asset_admin
|
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(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(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(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"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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!')
|
||||||
@@ -663,6 +663,27 @@ export const adminAPI = {
|
|||||||
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
|
getCodeExecTask: (id: string) => get<any>(`/admin/code-exec/tasks/${id}`),
|
||||||
deleteCodeExecTask: (id: string) => del<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 =====
|
// ===== AI自进化API =====
|
||||||
export const evolutionAPI = {
|
export const evolutionAPI = {
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const adminNavItems = [
|
|||||||
|
|
||||||
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
{ path: '/admin/agents', icon: 'agents', label: 'Agent管理' },
|
||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
{ 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> = {
|
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>,
|
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>,
|
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>,
|
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 AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuth();
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
Reference in new issue
Block a user