fix: 恢复 rsync 误删的近期开发(agents/skills/mcp/knowledge/workflow/tenant/billing/storage/privacy/promotion/achievement/automation/code-exec/developer/distribution/im-bot/portal/casdoor 等后端模块 + 对应前端页面路由, 补回 main.py 挂载与 App.tsx 路由

This commit is contained in:
qyyfw committed 2026-07-11 11:40:39 +08:00
1 parent d95b847fee
commit e8e5ffa1a9
65 files changed
+35624 -339

No files matched your search

+844
View File
@@ -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()