"""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()