feat: Phase 3.5 AI App Management - lifecycle, templates, versioning, analytics
This commit is contained in:
1 parent
e7d1c39c18
commit
6583bd2a62
7 files changed
+2185
-5
No files matched your search
@@ -0,0 +1,844 @@
|
||||
"""AI应用管理 - Admin API
|
||||
|
||||
提供AI应用的完整生命周期管理:
|
||||
- 应用CRUD + 生命周期(草稿→发布→暂停→归档)
|
||||
- 应用模板(快速创建)
|
||||
- 版本管理(配置快照、回滚)
|
||||
- 应用分析(对话数、Token消耗、活跃用户、响应时间)
|
||||
- 分类统计
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Body
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import text
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import uuid
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Database - use app's existing connection
|
||||
from ...database import SessionLocal
|
||||
|
||||
# Timezone
|
||||
from datetime import timezone
|
||||
CST_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def cst_now():
|
||||
return datetime.now(CST_TZ).replace(tzinfo=None)
|
||||
|
||||
|
||||
def fmt_time(dt_val):
|
||||
if not dt_val:
|
||||
return None
|
||||
if hasattr(dt_val, 'replace'):
|
||||
return dt_val.replace(tzinfo=CST_TZ).isoformat()
|
||||
return str(dt_val)
|
||||
|
||||
|
||||
def gen_id():
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# ============================================================
|
||||
# Pydantic Models
|
||||
# ============================================================
|
||||
|
||||
class AIAppCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
type: str = "chatbot" # chatbot / assistant / workflow / api_service
|
||||
category: str = "custom"
|
||||
tags: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
config: Optional[dict] = None
|
||||
|
||||
|
||||
class AIAppUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
tags: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
config: Optional[dict] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
|
||||
class AIAppVersionCreate(BaseModel):
|
||||
changelog: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
type: str = "chatbot"
|
||||
category: str = "custom"
|
||||
icon: Optional[str] = None
|
||||
config: dict
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stats Overview
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats/overview")
|
||||
async def stats_overview():
|
||||
"""AI应用总览统计"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Total apps
|
||||
total = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 0")).scalar() or 0
|
||||
published = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'published' AND is_template = 0")).scalar() or 0
|
||||
draft = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'draft' AND is_template = 0")).scalar() or 0
|
||||
paused = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'paused' AND is_template = 0")).scalar() or 0
|
||||
archived = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE status = 'archived' AND is_template = 0")).scalar() or 0
|
||||
templates = db.execute(text("SELECT COUNT(*) FROM ai_applications WHERE is_template = 1")).scalar() or 0
|
||||
|
||||
# By type
|
||||
type_rows = db.execute(text(
|
||||
"SELECT type, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY type"
|
||||
)).fetchall()
|
||||
by_type = {row[0]: row[1] for row in type_rows}
|
||||
|
||||
# By category
|
||||
cat_rows = db.execute(text(
|
||||
"SELECT category, COUNT(*) as cnt FROM ai_applications WHERE is_template = 0 GROUP BY category"
|
||||
)).fetchall()
|
||||
by_category = {row[0]: row[1] for row in cat_rows}
|
||||
|
||||
# Recent 7 days new apps
|
||||
seven_days_ago = cst_now() - timedelta(days=7)
|
||||
recent = db.execute(text(
|
||||
"SELECT COUNT(*) FROM ai_applications WHERE created_at >= :dt AND is_template = 0"
|
||||
), {"dt": seven_days_ago}).scalar() or 0
|
||||
|
||||
# Total versions
|
||||
total_versions = db.execute(text("SELECT COUNT(*) FROM ai_app_versions")).scalar() or 0
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total": total,
|
||||
"published": published,
|
||||
"draft": draft,
|
||||
"paused": paused,
|
||||
"archived": archived,
|
||||
"templates": templates,
|
||||
"by_type": by_type,
|
||||
"by_category": by_category,
|
||||
"recent_7d": recent,
|
||||
"total_versions": total_versions
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# App CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/apps")
|
||||
async def list_apps(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
search: Optional[str] = Query(None),
|
||||
app_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
is_template: bool = Query(False)
|
||||
):
|
||||
"""获取应用列表"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
where = "WHERE is_template = :is_template"
|
||||
params = {"is_template": 1 if is_template else 0}
|
||||
|
||||
if search:
|
||||
where += " AND (name LIKE :search OR description LIKE :search2)"
|
||||
params["search"] = f"%{search}%"
|
||||
params["search2"] = f"%{search}%"
|
||||
if app_type:
|
||||
where += " AND type = :app_type"
|
||||
params["app_type"] = app_type
|
||||
if status:
|
||||
where += " AND status = :status"
|
||||
params["status"] = status
|
||||
if category:
|
||||
where += " AND category = :category"
|
||||
params["category"] = category
|
||||
|
||||
total = db.execute(text(f"SELECT COUNT(*) FROM ai_applications {where}"), params).scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
rows = db.execute(text(
|
||||
f"SELECT id, name, description, type, category, tags, icon, status, version, "
|
||||
f"is_template, is_public, config, created_at, updated_at, published_at "
|
||||
f"FROM ai_applications {where} ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
||||
), {**params, "limit": page_size, "offset": offset}).fetchall()
|
||||
|
||||
apps = []
|
||||
for row in rows:
|
||||
config = row[11]
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config)
|
||||
except:
|
||||
config = {}
|
||||
apps.append({
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"description": row[2],
|
||||
"type": row[3],
|
||||
"category": row[4],
|
||||
"tags": row[5],
|
||||
"icon": row[6],
|
||||
"status": row[7],
|
||||
"version": row[8],
|
||||
"is_template": bool(row[9]),
|
||||
"is_public": bool(row[10]),
|
||||
"config": config,
|
||||
"created_at": fmt_time(row[12]),
|
||||
"updated_at": fmt_time(row[13]),
|
||||
"published_at": fmt_time(row[14])
|
||||
})
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": apps,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": (total + page_size - 1) // page_size
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps")
|
||||
async def create_app(app: AIAppCreate):
|
||||
"""创建AI应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
app_id = gen_id()
|
||||
now = cst_now()
|
||||
config_json = json.dumps(app.config or {
|
||||
"model_id": None,
|
||||
"system_prompt": "",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2000,
|
||||
"skill_ids": [],
|
||||
"knowledge_base_ids": [],
|
||||
"tools": [],
|
||||
"welcome_message": ""
|
||||
}, ensure_ascii=False)
|
||||
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
|
||||
"status, version, is_template, is_public, config, created_at, updated_at) "
|
||||
"VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)"
|
||||
), {
|
||||
"id": app_id, "name": app.name, "desc": app.description,
|
||||
"type": app.type, "cat": app.category, "tags": app.tags,
|
||||
"icon": app.icon, "config": config_json, "now": now
|
||||
})
|
||||
db.commit()
|
||||
|
||||
# Save initial version
|
||||
version_id = gen_id()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:id, :app_id, 1, :config, :changelog, :now)"
|
||||
), {
|
||||
"id": version_id, "app_id": app_id, "config": config_json,
|
||||
"changelog": "Initial version", "now": now
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {"code": 0, "data": {"id": app_id, "message": "Application created"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/apps/{app_id}")
|
||||
async def get_app(app_id: str):
|
||||
"""获取应用详情"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text(
|
||||
"SELECT id, name, description, type, category, tags, icon, status, version, "
|
||||
"is_template, is_public, config, created_at, updated_at, published_at "
|
||||
"FROM ai_applications WHERE id = :id"
|
||||
), {"id": app_id}).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Application not found")
|
||||
|
||||
config = row[11]
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config)
|
||||
except:
|
||||
config = {}
|
||||
|
||||
# Get versions
|
||||
version_rows = db.execute(text(
|
||||
"SELECT id, version, changelog, created_at FROM ai_app_versions "
|
||||
"WHERE app_id = :id ORDER BY version DESC"
|
||||
), {"id": app_id}).fetchall()
|
||||
|
||||
versions = [{
|
||||
"id": v[0], "version": v[1], "changelog": v[2],
|
||||
"created_at": fmt_time(v[3])
|
||||
} for v in version_rows]
|
||||
|
||||
# Get analytics summary (last 7 days)
|
||||
seven_days_ago = cst_now() - timedelta(days=7)
|
||||
analytics = db.execute(text(
|
||||
"SELECT COALESCE(SUM(conversations), 0), COALESCE(SUM(messages), 0), "
|
||||
"COALESCE(SUM(tokens_input + tokens_output), 0), COALESCE(SUM(active_users), 0), "
|
||||
"COALESCE(SUM(error_count), 0) "
|
||||
"FROM ai_app_analytics WHERE app_id = :id AND date >= :dt"
|
||||
), {"id": app_id, "dt": seven_days_ago}).fetchone()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": row[0], "name": row[1], "description": row[2],
|
||||
"type": row[3], "category": row[4], "tags": row[5],
|
||||
"icon": row[6], "status": row[7], "version": row[8],
|
||||
"is_template": bool(row[9]), "is_public": bool(row[10]),
|
||||
"config": config,
|
||||
"created_at": fmt_time(row[12]),
|
||||
"updated_at": fmt_time(row[13]),
|
||||
"published_at": fmt_time(row[14]),
|
||||
"versions": versions,
|
||||
"analytics_7d": {
|
||||
"conversations": analytics[0],
|
||||
"messages": analytics[1],
|
||||
"tokens": analytics[2],
|
||||
"active_users": analytics[3],
|
||||
"errors": analytics[4]
|
||||
}
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/apps/{app_id}")
|
||||
async def update_app(app_id: str, app: AIAppUpdate):
|
||||
"""更新应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Application not found")
|
||||
|
||||
old_config = existing[0]
|
||||
old_version = existing[1]
|
||||
now = cst_now()
|
||||
|
||||
updates = []
|
||||
params = {"id": app_id, "now": now}
|
||||
|
||||
if app.name is not None:
|
||||
updates.append("name = :name")
|
||||
params["name"] = app.name
|
||||
if app.description is not None:
|
||||
updates.append("description = :desc")
|
||||
params["desc"] = app.description
|
||||
if app.type is not None:
|
||||
updates.append("type = :type")
|
||||
params["type"] = app.type
|
||||
if app.category is not None:
|
||||
updates.append("category = :cat")
|
||||
params["cat"] = app.category
|
||||
if app.tags is not None:
|
||||
updates.append("tags = :tags")
|
||||
params["tags"] = app.tags
|
||||
if app.icon is not None:
|
||||
updates.append("icon = :icon")
|
||||
params["icon"] = app.icon
|
||||
if app.status is not None:
|
||||
updates.append("status = :status")
|
||||
params["status"] = app.status
|
||||
if app.status == "published":
|
||||
updates.append("published_at = :now")
|
||||
|
||||
config_changed = False
|
||||
if app.config is not None:
|
||||
new_config = json.dumps(app.config, ensure_ascii=False)
|
||||
if new_config != old_config:
|
||||
updates.append("config = :config")
|
||||
params["config"] = new_config
|
||||
config_changed = True
|
||||
|
||||
if not updates:
|
||||
return {"code": 0, "data": {"message": "No changes"}}
|
||||
|
||||
updates.append("updated_at = :now")
|
||||
db.execute(text(f"UPDATE ai_applications SET {', '.join(updates)} WHERE id = :id"), params)
|
||||
|
||||
# Save new version if config changed
|
||||
if config_changed:
|
||||
new_version = old_version + 1
|
||||
db.execute(text("UPDATE ai_applications SET version = :v WHERE id = :id"), {"v": new_version, "id": app_id})
|
||||
version_id = gen_id()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
|
||||
), {
|
||||
"vid": version_id, "aid": app_id, "v": new_version,
|
||||
"config": new_config, "cl": f"Version {new_version}", "now": now
|
||||
})
|
||||
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"message": "Application updated"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/apps/{app_id}")
|
||||
async def delete_app(app_id: str):
|
||||
"""删除应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text("DELETE FROM ai_applications WHERE id = :id"), {"id": app_id})
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(404, "Application not found")
|
||||
db.execute(text("DELETE FROM ai_app_versions WHERE app_id = :id"), {"id": app_id})
|
||||
db.execute(text("DELETE FROM ai_app_analytics WHERE app_id = :id"), {"id": app_id})
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"message": "Application deleted"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lifecycle Management
|
||||
# ============================================================
|
||||
|
||||
@router.post("/apps/{app_id}/publish")
|
||||
async def publish_app(app_id: str):
|
||||
"""发布应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
now = cst_now()
|
||||
result = db.execute(text(
|
||||
"UPDATE ai_applications SET status = 'published', published_at = :now, updated_at = :now WHERE id = :id"
|
||||
), {"id": app_id, "now": now})
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(404, "Application not found")
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"message": "Application published"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/pause")
|
||||
async def pause_app(app_id: str):
|
||||
"""暂停应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text(
|
||||
"UPDATE ai_applications SET status = 'paused', updated_at = :now WHERE id = :id"
|
||||
), {"id": app_id, "now": cst_now()})
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(404, "Application not found")
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"message": "Application paused"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/archive")
|
||||
async def archive_app(app_id: str):
|
||||
"""归档应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
result = db.execute(text(
|
||||
"UPDATE ai_applications SET status = 'archived', updated_at = :now WHERE id = :id"
|
||||
), {"id": app_id, "now": cst_now()})
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(404, "Application not found")
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"message": "Application archived"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/duplicate")
|
||||
async def duplicate_app(app_id: str):
|
||||
"""复制应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text(
|
||||
"SELECT name, description, type, category, tags, icon, config "
|
||||
"FROM ai_applications WHERE id = :id"
|
||||
), {"id": app_id}).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Application not found")
|
||||
|
||||
new_id = gen_id()
|
||||
now = cst_now()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
|
||||
"status, version, is_template, is_public, config, created_at, updated_at) "
|
||||
"VALUES (:id, :name, :desc, :type, :cat, :tags, :icon, 'draft', 1, 0, 0, :config, :now, :now)"
|
||||
), {
|
||||
"id": new_id, "name": row[0] + " (Copy)", "desc": row[1],
|
||||
"type": row[2], "cat": row[3], "tags": row[4],
|
||||
"icon": row[5], "config": row[6], "now": now
|
||||
})
|
||||
|
||||
# Copy initial version
|
||||
version_id = gen_id()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:vid, :aid, 1, :config, :cl, :now)"
|
||||
), {"vid": version_id, "aid": new_id, "config": row[6], "cl": "Copied from original", "now": now})
|
||||
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"id": new_id, "message": "Application duplicated"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Version Management
|
||||
# ============================================================
|
||||
|
||||
@router.get("/apps/{app_id}/versions")
|
||||
async def list_versions(app_id: str):
|
||||
"""获取应用版本历史"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text(
|
||||
"SELECT id, version, config, changelog, created_at "
|
||||
"FROM ai_app_versions WHERE app_id = :id ORDER BY version DESC"
|
||||
), {"id": app_id}).fetchall()
|
||||
|
||||
versions = []
|
||||
for row in rows:
|
||||
config = row[2]
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config)
|
||||
except:
|
||||
config = {}
|
||||
versions.append({
|
||||
"id": row[0], "version": row[1], "config": config,
|
||||
"changelog": row[3], "created_at": fmt_time(row[4])
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"items": versions, "total": len(versions)}}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/versions")
|
||||
async def save_version(app_id: str, body: AIAppVersionCreate):
|
||||
"""保存当前配置为新版本"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text("SELECT config, version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Application not found")
|
||||
|
||||
new_version = row[1] + 1
|
||||
version_id = gen_id()
|
||||
now = cst_now()
|
||||
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
|
||||
), {
|
||||
"vid": version_id, "aid": app_id, "v": new_version,
|
||||
"config": row[0], "cl": body.changelog or f"Version {new_version}", "now": now
|
||||
})
|
||||
db.execute(text("UPDATE ai_applications SET version = :v, updated_at = :now WHERE id = :id"),
|
||||
{"v": new_version, "now": now, "id": app_id})
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"version": new_version, "message": "Version saved"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/{app_id}/versions/{version}/restore")
|
||||
async def restore_version(app_id: str, version: int):
|
||||
"""恢复到指定版本"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text(
|
||||
"SELECT config FROM ai_app_versions WHERE app_id = :id AND version = :v"
|
||||
), {"id": app_id, "v": version}).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Version not found")
|
||||
|
||||
now = cst_now()
|
||||
current = db.execute(text("SELECT version FROM ai_applications WHERE id = :id"), {"id": app_id}).fetchone()
|
||||
if not current:
|
||||
raise HTTPException(404, "Application not found")
|
||||
|
||||
new_version = current[0] + 1
|
||||
db.execute(text(
|
||||
"UPDATE ai_applications SET config = :config, version = :v, updated_at = :now WHERE id = :id"
|
||||
), {"config": row[0], "v": new_version, "now": now, "id": app_id})
|
||||
|
||||
# Save restored version
|
||||
version_id = gen_id()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:vid, :aid, :v, :config, :cl, :now)"
|
||||
), {
|
||||
"vid": version_id, "aid": app_id, "v": new_version,
|
||||
"config": row[0], "cl": f"Restored from v{version}", "now": now
|
||||
})
|
||||
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"version": new_version, "message": f"Restored to v{version}"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Analytics
|
||||
# ============================================================
|
||||
|
||||
@router.get("/apps/{app_id}/analytics")
|
||||
async def app_analytics(app_id: str, days: int = Query(7, ge=1, le=90)):
|
||||
"""获取应用分析数据"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
start_date = cst_now() - timedelta(days=days)
|
||||
rows = db.execute(text(
|
||||
"SELECT date, conversations, messages, tokens_input, tokens_output, "
|
||||
"active_users, avg_response_ms, error_count "
|
||||
"FROM ai_app_analytics WHERE app_id = :id AND date >= :dt ORDER BY date"
|
||||
), {"id": app_id, "dt": start_date}).fetchall()
|
||||
|
||||
daily = []
|
||||
for row in rows:
|
||||
daily.append({
|
||||
"date": row[0].isoformat() if row[0] else None,
|
||||
"conversations": row[1], "messages": row[2],
|
||||
"tokens_input": row[3], "tokens_output": row[4],
|
||||
"active_users": row[5], "avg_response_ms": row[6],
|
||||
"errors": row[7]
|
||||
})
|
||||
|
||||
# Summary
|
||||
total_convs = sum(d["conversations"] for d in daily)
|
||||
total_msgs = sum(d["messages"] for d in daily)
|
||||
total_tokens_in = sum(d["tokens_input"] for d in daily)
|
||||
total_tokens_out = sum(d["tokens_output"] for d in daily)
|
||||
total_users = sum(d["active_users"] for d in daily)
|
||||
total_errors = sum(d["errors"] for d in daily)
|
||||
avg_response = sum(d["avg_response_ms"] for d in daily) / len(daily) if daily else 0
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"daily": daily,
|
||||
"summary": {
|
||||
"conversations": total_convs,
|
||||
"messages": total_msgs,
|
||||
"tokens_input": total_tokens_in,
|
||||
"tokens_output": total_tokens_out,
|
||||
"active_users": total_users,
|
||||
"errors": total_errors,
|
||||
"avg_response_ms": round(avg_response, 1),
|
||||
"error_rate": round(total_errors / total_msgs * 100, 2) if total_msgs else 0
|
||||
},
|
||||
"days": days
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Templates
|
||||
# ============================================================
|
||||
|
||||
@router.get("/templates")
|
||||
async def list_templates(
|
||||
type: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None)
|
||||
):
|
||||
"""获取应用模板列表"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
where = "WHERE is_template = 1"
|
||||
params = {}
|
||||
if type:
|
||||
where += " AND type = :type"
|
||||
params["type"] = type
|
||||
if category:
|
||||
where += " AND category = :category"
|
||||
params["category"] = category
|
||||
|
||||
rows = db.execute(text(
|
||||
f"SELECT id, name, description, type, category, icon, config, created_at "
|
||||
f"FROM ai_applications {where} ORDER BY created_at DESC"
|
||||
), params).fetchall()
|
||||
|
||||
templates = []
|
||||
for row in rows:
|
||||
config = row[6]
|
||||
if isinstance(config, str):
|
||||
try:
|
||||
config = json.loads(config)
|
||||
except:
|
||||
config = {}
|
||||
templates.append({
|
||||
"id": row[0], "name": row[1], "description": row[2],
|
||||
"type": row[3], "category": row[4], "icon": row[5],
|
||||
"config": config, "created_at": fmt_time(row[7])
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"items": templates, "total": len(templates)}}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(tpl: TemplateCreate):
|
||||
"""创建应用模板"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
tpl_id = gen_id()
|
||||
now = cst_now()
|
||||
config_json = json.dumps(tpl.config, ensure_ascii=False)
|
||||
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_applications (id, name, description, type, category, icon, "
|
||||
"status, version, is_template, is_public, config, created_at, updated_at) "
|
||||
"VALUES (:id, :name, :desc, :type, :cat, :icon, 'published', 1, 1, 1, :config, :now, :now)"
|
||||
), {
|
||||
"id": tpl_id, "name": tpl.name, "desc": tpl.description,
|
||||
"type": tpl.type, "cat": tpl.category, "icon": tpl.icon,
|
||||
"config": config_json, "now": now
|
||||
})
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"id": tpl_id, "message": "Template created"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/apps/from-template/{template_id}")
|
||||
async def create_from_template(template_id: str, body: dict = Body(default={})):
|
||||
"""从模板创建应用"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.execute(text(
|
||||
"SELECT name, description, type, category, config FROM ai_applications WHERE id = :id AND is_template = 1"
|
||||
), {"id": template_id}).fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
|
||||
new_id = gen_id()
|
||||
now = cst_now()
|
||||
custom_name = body.get("name", row[0] + " (Instance)")
|
||||
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_applications (id, name, description, type, category, tags, icon, "
|
||||
"status, version, is_template, is_public, config, created_at, updated_at) "
|
||||
"VALUES (:id, :name, :desc, :type, :cat, NULL, NULL, 'draft', 1, 0, 0, :config, :now, :now)"
|
||||
), {
|
||||
"id": new_id, "name": custom_name, "desc": row[1],
|
||||
"type": row[2], "cat": row[3], "config": row[4], "now": now
|
||||
})
|
||||
|
||||
version_id = gen_id()
|
||||
db.execute(text(
|
||||
"INSERT INTO ai_app_versions (id, app_id, version, config, changelog, created_at) "
|
||||
"VALUES (:vid, :aid, 1, :config, :cl, :now)"
|
||||
), {"vid": version_id, "aid": new_id, "config": row[4], "cl": "Created from template", "now": now})
|
||||
|
||||
db.commit()
|
||||
return {"code": 0, "data": {"id": new_id, "message": "Application created from template"}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(500, str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Categories
|
||||
# ============================================================
|
||||
|
||||
@router.get("/categories")
|
||||
async def list_categories():
|
||||
"""获取应用分类统计"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.execute(text(
|
||||
"SELECT category, COUNT(*) as cnt, "
|
||||
"SUM(CASE WHEN status = 'published' THEN 1 ELSE 0 END) as published "
|
||||
"FROM ai_applications WHERE is_template = 0 "
|
||||
"GROUP BY category ORDER BY cnt DESC"
|
||||
)).fetchall()
|
||||
|
||||
categories = []
|
||||
category_names = {
|
||||
"customer_service": "客服助手",
|
||||
"data_analysis": "数据分析",
|
||||
"coding": "编程辅助",
|
||||
"writing": "内容创作",
|
||||
"education": "教育培训",
|
||||
"marketing": "营销推广",
|
||||
"hr": "人力资源",
|
||||
"finance": "财务法务",
|
||||
"custom": "自定义"
|
||||
}
|
||||
for row in rows:
|
||||
categories.append({
|
||||
"key": row[0],
|
||||
"name": category_names.get(row[0], row[0]),
|
||||
"count": row[1],
|
||||
"published": row[2]
|
||||
})
|
||||
|
||||
return {"code": 0, "data": {"items": categories}}
|
||||
finally:
|
||||
db.close()
|
||||
Reference in new issue
Block a user