One-time alignment to close drift between the running container (qihu-aiflow / image qihuaiflow:with-deps) and this Git repository. Container hotfixes previously persisted only via `docker commit` are now captured in Git so Git becomes the single source of truth. Backend: - chat_service.py: accumulated fixes incl. EigenFlux README guidance - chat_context.py / chat_media.py: URL/web-repo context injection - api/url_reader.py: NEW SSRF-safe web/README reader (Issue #725) - skills_admin.py & Skills.tsx: 14-scene capability unification - pgvector_search.py, orchestrator.py, platform_knowledge.py, market.py, admin/models.py: drift sync Frontend: - ChatPage.tsx: collapsible AI think/act block (Issue #724) - MarketPage / EvolutionDashboard / TokenDashboard styling sync Migrations: - 018_normalize_marketapp_scene.sql: fixed capability allowlist (prevents capability wipe on restart) Alignment performed 2026-07-23 05:42:47. No deletions; secrets/venv/node_modules/ caches were not touched (tracked-source-only copy).
959 lines
35 KiB
Python
959 lines
35 KiB
Python
"""
|
||
技能管理系统 API(管理员)
|
||
全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场
|
||
"""
|
||
import os
|
||
import json
|
||
import uuid
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Optional, List
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import desc, func, or_, and_, text
|
||
|
||
from ...database import get_db
|
||
from ...models import (
|
||
User, EvolutionSkill, SkillVersion, SkillPublishRecord,
|
||
EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation,
|
||
AppPurchase, AppReview
|
||
)
|
||
from ...auth import get_current_admin
|
||
from ...middleware.log_service import LogService
|
||
from ...config import UPLOAD_DIR
|
||
|
||
router = APIRouter()
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ============================================================
|
||
# 技能状态定义
|
||
# ============================================================
|
||
|
||
SKILL_STATES = {
|
||
"baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"},
|
||
"main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"},
|
||
"staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"},
|
||
"frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"},
|
||
}
|
||
|
||
# 业务场景分类(与前端「应用市场」保持一致,14 类岗位/职能视角)
|
||
# 统一标准:以前台 MARKET_SCENES 为准,后台技能管理也按此 14 类展示与筛选。
|
||
SKILL_CATEGORIES = [
|
||
{"id": "design", "label": "🎨 做设计"},
|
||
{"id": "frontend", "label": "💻 写前端"},
|
||
{"id": "mobile", "label": "📱 做移动端"},
|
||
{"id": "backend", "label": "🧪 后端/全栈"},
|
||
{"id": "qa", "label": "🧪 测试质量"},
|
||
{"id": "search", "label": "🔍 搜索信息"},
|
||
{"id": "docs", "label": "✍️ 写文档"},
|
||
{"id": "marketing", "label": "📢 推广获客"},
|
||
{"id": "workflow", "label": "🔄 工作流"},
|
||
{"id": "secops", "label": "🛡️ 安全运维"},
|
||
{"id": "learning", "label": "🎓 学习教学"},
|
||
{"id": "video", "label": "🎬 视频多媒体"},
|
||
{"id": "collab", "label": "🏢 办公协作"},
|
||
{"id": "ai", "label": "🛠️ AI能力"},
|
||
]
|
||
_SCENE_IDS = {c["id"] for c in SKILL_CATEGORIES}
|
||
# 旧 9 类 taxonomy -> 14 场景 的兜底映射(仅用于历史数据 capability 缺失时)
|
||
_CATEGORY_TO_SCENE = {
|
||
"writing": "docs", "coding": "backend", "data_analysis": "backend",
|
||
"qa": "qa", "automation": "workflow", "creativity": "design",
|
||
"communication": "marketing", "reasoning": "search", "other": "workflow",
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 辅助函数
|
||
# ============================================================
|
||
|
||
def market_app_to_skill_dict(app: "MarketApp") -> dict:
|
||
"""将市场应用(MarketApp type=skill)转换为与 EvolutionSkill 一致的字典格式"""
|
||
status = (getattr(app, "status", None) or "pending").strip()
|
||
state = "main" if status == "published" else "baby"
|
||
raw_tags = getattr(app, "tags", None)
|
||
tags_list = []
|
||
if raw_tags:
|
||
try:
|
||
tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags)
|
||
except Exception:
|
||
tags_list = [t.strip() for t in str(raw_tags).split(",")]
|
||
# 以业务场景(capability, 14 类)为分类标准,与前端应用市场统一;
|
||
# capability 缺失时回退到旧 9 类 category 映射出的场景。
|
||
raw_scene = getattr(app, "capability", None)
|
||
if raw_scene in _SCENE_IDS:
|
||
category = raw_scene
|
||
else:
|
||
category = _CATEGORY_TO_SCENE.get(getattr(app, "category", None), "workflow")
|
||
return {
|
||
"id": app.id,
|
||
"user_id": getattr(app, "publisher_id", None) or "system",
|
||
"skill_name": app.name,
|
||
"description": getattr(app, "description", None) or "",
|
||
"tags": tags_list,
|
||
"category": category,
|
||
"category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == category), category or "其他"),
|
||
"state": state,
|
||
"state_info": SKILL_STATES.get(state, SKILL_STATES["baby"]),
|
||
"use_count": getattr(app, "install_count", None) or 0,
|
||
"version": 1,
|
||
"skill_md_path": None,
|
||
"git_commit_sha": None,
|
||
"last_used_at": None,
|
||
"created_at": app.created_at.isoformat() if getattr(app, "created_at", None) else None,
|
||
"updated_at": getattr(app, "updated_at", None).isoformat() if getattr(app, "updated_at", None) else None,
|
||
"source": "market",
|
||
# 让前端 isPublished(skill) 能正确识别 market 已发布项
|
||
"publish_records": [{"status": "published"}] if status == "published" else [],
|
||
}
|
||
|
||
|
||
def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict:
|
||
"""将技能ORM对象转换为字典"""
|
||
result = {
|
||
"id": s.id,
|
||
"user_id": s.user_id,
|
||
"skill_name": s.skill_name,
|
||
"description": s.description,
|
||
"tags": s.tags or [],
|
||
"category": s.category,
|
||
"category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"),
|
||
"state": s.state,
|
||
"state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]),
|
||
"use_count": s.use_count or 0,
|
||
"version": s.version or 1,
|
||
"skill_md_path": s.skill_md_path,
|
||
"git_commit_sha": s.git_commit_sha,
|
||
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
|
||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||
}
|
||
|
||
# 读取 SKILL.md 内容
|
||
if include_md and s.skill_md_path and os.path.exists(s.skill_md_path):
|
||
try:
|
||
with open(s.skill_md_path, 'r', encoding='utf-8') as f:
|
||
result["skill_md_content"] = f.read()
|
||
except Exception:
|
||
result["skill_md_content"] = None
|
||
else:
|
||
result["skill_md_content"] = None
|
||
|
||
return result
|
||
|
||
|
||
def version_to_dict(v: SkillVersion) -> dict:
|
||
return {
|
||
"id": v.id,
|
||
"user_id": v.user_id,
|
||
"skill_name": v.skill_name,
|
||
"version": v.version,
|
||
"skill_md_content": v.skill_md_content,
|
||
"git_commit_sha": v.git_commit_sha,
|
||
"description": v.description,
|
||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 管理员技能列表
|
||
# ============================================================
|
||
|
||
@router.get("/")
|
||
async def list_skills(
|
||
state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"),
|
||
category: str = Query(None, description="技能分类过滤"),
|
||
keyword: str = Query(None, description="关键词搜索(名称/描述)"),
|
||
user_id: str = Query(None, description="按用户ID筛选"),
|
||
sort_by: str = Query("updated_at", description="排序字段"),
|
||
order: str = Query("desc", description="排序方向: asc/desc"),
|
||
page: int = Query(1, ge=1, description="页码"),
|
||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取全平台技能列表(支持分页/过滤/搜索)—— 同时查询 EvolutionSkill + MarketApp(type=skill)"""
|
||
logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}")
|
||
|
||
result: list = []
|
||
|
||
# ===== 1) 查询 EvolutionSkill =====
|
||
evo_query = db.query(EvolutionSkill)
|
||
if state:
|
||
evo_query = evo_query.filter(EvolutionSkill.state == state)
|
||
if category:
|
||
evo_query = evo_query.filter(EvolutionSkill.category == category)
|
||
if keyword:
|
||
evo_query = evo_query.filter(
|
||
or_(EvolutionSkill.skill_name.contains(keyword), EvolutionSkill.description.contains(keyword))
|
||
)
|
||
if user_id:
|
||
evo_query = evo_query.filter(EvolutionSkill.user_id == user_id)
|
||
|
||
evo_skills = evo_query.all()
|
||
user_ids = list(set(s.user_id for s in evo_skills))
|
||
users_map = {u.id: u.username for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()}
|
||
for s in evo_skills:
|
||
item = skill_to_dict(s)
|
||
item["owner_username"] = users_map.get(s.user_id, s.user_id)
|
||
item["source"] = "evolution"
|
||
result.append(item)
|
||
|
||
# ===== 2) 查询 MarketApp (type=skill) =====
|
||
mkt_query = db.query(MarketApp).filter(MarketApp.type == "skill")
|
||
if keyword:
|
||
mkt_query = mkt_query.filter(
|
||
or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword))
|
||
)
|
||
if category:
|
||
# 分类筛选统一按业务场景(capability, 14 类)进行,与前端应用市场一致
|
||
mkt_query = mkt_query.filter(MarketApp.capability == category)
|
||
# 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby'
|
||
if state == "main":
|
||
mkt_query = mkt_query.filter(MarketApp.status == "published")
|
||
elif state:
|
||
mkt_query = mkt_query.filter(MarketApp.status != "published") # 非发布=草稿等
|
||
|
||
market_apps_rows = mkt_query.all()
|
||
for app in market_apps_rows:
|
||
item = market_app_to_skill_dict(app)
|
||
item["owner_username"] = app.publisher_name or "system"
|
||
result.append(item)
|
||
|
||
# ===== 3) 合并排序 & 分页 =====
|
||
total = len(result)
|
||
reverse = order == "desc"
|
||
key_fn = lambda x: x.get(sort_by) or ""
|
||
result.sort(key=key_fn, reverse=reverse)
|
||
|
||
start = (page - 1) * page_size
|
||
end = start + page_size
|
||
paged = result[start:end]
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"items": paged,
|
||
"total": total,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"pages": (total + page_size - 1) // page_size if total else 0,
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 获取单个技能详情
|
||
# ============================================================
|
||
|
||
@router.get("/{skill_id}")
|
||
async def get_skill(
|
||
skill_id: str,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取技能详情(包含 SKILL.md 内容)"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
result = skill_to_dict(skill, include_md=True)
|
||
|
||
# 获取所属用户信息
|
||
user = db.query(User).filter(User.id == skill.user_id).first()
|
||
if user:
|
||
result["owner_username"] = user.username
|
||
result["owner_email"] = user.email
|
||
|
||
# 获取发布记录
|
||
pub_records = db.query(SkillPublishRecord).filter(
|
||
SkillPublishRecord.skill_id == skill_id
|
||
).order_by(desc(SkillPublishRecord.created_at)).limit(10).all()
|
||
result["publish_records"] = [{
|
||
"id": p.id,
|
||
"price": float(p.price) if p.price else 0,
|
||
"market_description": p.market_description,
|
||
"status": p.status,
|
||
"published_at": p.published_at.isoformat() if p.published_at else None,
|
||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||
} for p in pub_records]
|
||
|
||
# 获取最新版本号
|
||
latest_version = db.query(func.max(SkillVersion.version)).filter(
|
||
SkillVersion.skill_id == skill_id
|
||
).scalar()
|
||
result["latest_version"] = latest_version or skill.version
|
||
|
||
return {"code": 0, "data": result}
|
||
|
||
|
||
# ============================================================
|
||
# 创建技能
|
||
# ============================================================
|
||
|
||
class CreateSkillRequest:
|
||
def __init__(self, skill_name: str = None, description: str = None,
|
||
tags: List[str] = None, category: str = None,
|
||
skill_md_content: str = None, user_id: str = None):
|
||
self.skill_name = skill_name
|
||
self.description = description
|
||
self.tags = tags or []
|
||
self.category = category
|
||
self.skill_md_content = skill_md_content
|
||
self.user_id = user_id
|
||
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class CreateSkillSchema(BaseModel):
|
||
skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称")
|
||
user_id: str = Field(..., description="所属用户ID")
|
||
description: Optional[str] = Field(None, description="技能描述")
|
||
tags: Optional[List[str]] = Field(default_factory=list, description="标签列表")
|
||
category: Optional[str] = Field(None, description="技能分类")
|
||
skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容")
|
||
|
||
|
||
@router.post("/")
|
||
async def create_skill(
|
||
req: CreateSkillSchema,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""管理员创建技能"""
|
||
logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}")
|
||
|
||
# 检查技能名是否已存在
|
||
existing = db.query(EvolutionSkill).filter(
|
||
EvolutionSkill.user_id == req.user_id,
|
||
EvolutionSkill.skill_name == req.skill_name
|
||
).first()
|
||
if existing:
|
||
raise HTTPException(status_code=400, detail="该用户已存在同名技能")
|
||
|
||
# 验证用户存在
|
||
user = db.query(User).filter(User.id == req.user_id).first()
|
||
if not user:
|
||
raise HTTPException(status_code=400, detail="用户不存在")
|
||
|
||
# 生成技能ID
|
||
skill_id = str(uuid.uuid4())
|
||
|
||
# 保存 SKILL.md 文件
|
||
skill_md_path = None
|
||
if req.skill_md_content:
|
||
skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id)
|
||
os.makedirs(skill_dir, exist_ok=True)
|
||
safe_name = req.skill_name.replace("/", "_").replace("\\", "_")
|
||
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
|
||
try:
|
||
with open(skill_md_path, 'w', encoding='utf-8') as f:
|
||
f.write(req.skill_md_content)
|
||
except Exception as e:
|
||
logger.warning(f"保存 SKILL.md 失败: {e}")
|
||
|
||
# 创建技能记录
|
||
skill = EvolutionSkill(
|
||
id=skill_id,
|
||
user_id=req.user_id,
|
||
skill_name=req.skill_name,
|
||
description=req.description,
|
||
tags=req.tags or [],
|
||
category=req.category,
|
||
state="baby",
|
||
use_count=0,
|
||
version=1,
|
||
skill_md_path=skill_md_path,
|
||
git_commit_sha=None,
|
||
)
|
||
db.add(skill)
|
||
|
||
# 记录初始版本
|
||
if req.skill_md_content:
|
||
sv = SkillVersion(
|
||
id=str(uuid.uuid4()),
|
||
user_id=req.user_id,
|
||
skill_name=req.skill_name,
|
||
version=1,
|
||
skill_md_content=req.skill_md_content,
|
||
git_commit_sha=None,
|
||
description="初始版本",
|
||
)
|
||
db.add(sv)
|
||
|
||
# 记录事件
|
||
ev = EvolutionEvent(
|
||
id=str(uuid.uuid4()),
|
||
user_id=req.user_id,
|
||
skill_name=req.skill_name,
|
||
event_type="created",
|
||
description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}",
|
||
version_before=None,
|
||
version_after=1,
|
||
)
|
||
db.add(ev)
|
||
|
||
db.commit()
|
||
db.refresh(skill)
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="create_skill",
|
||
target="evolution_skills",
|
||
detail=f"创建技能 {req.skill_name}(归属用户: {user.username})",
|
||
)
|
||
|
||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||
|
||
|
||
# ============================================================
|
||
# 更新技能
|
||
# ============================================================
|
||
|
||
class UpdateSkillSchema(BaseModel):
|
||
skill_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||
description: Optional[str] = None
|
||
tags: Optional[List[str]] = None
|
||
category: Optional[str] = None
|
||
skill_md_content: Optional[str] = None
|
||
|
||
|
||
@router.put("/{skill_id}")
|
||
async def update_skill(
|
||
skill_id: str,
|
||
req: UpdateSkillSchema,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""更新技能信息"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
old_version = skill.version
|
||
update_data = req.model_dump(exclude_none=True)
|
||
|
||
# 处理 SKILL.md 内容
|
||
if "skill_md_content" in update_data:
|
||
content = update_data.pop("skill_md_content")
|
||
if content is not None:
|
||
skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id)
|
||
os.makedirs(skill_dir, exist_ok=True)
|
||
safe_name = skill.skill_name.replace("/", "_").replace("\\", "_")
|
||
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
|
||
try:
|
||
with open(skill_md_path, 'w', encoding='utf-8') as f:
|
||
f.write(content)
|
||
skill.skill_md_path = skill_md_path
|
||
except Exception as e:
|
||
logger.warning(f"保存 SKILL.md 失败: {e}")
|
||
|
||
# 记录新版本
|
||
new_version = old_version + 1
|
||
sv = SkillVersion(
|
||
id=str(uuid.uuid4()),
|
||
user_id=skill.user_id,
|
||
skill_name=skill.skill_name,
|
||
version=new_version,
|
||
skill_md_content=content,
|
||
git_commit_sha=None,
|
||
description="管理员更新",
|
||
)
|
||
db.add(sv)
|
||
skill.version = new_version
|
||
|
||
# 记录事件
|
||
ev = EvolutionEvent(
|
||
id=str(uuid.uuid4()),
|
||
user_id=skill.user_id,
|
||
skill_name=skill.skill_name,
|
||
event_type="edited",
|
||
description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}",
|
||
version_before=old_version,
|
||
version_after=new_version,
|
||
)
|
||
db.add(ev)
|
||
|
||
# 更新其他字段
|
||
for key, value in update_data.items():
|
||
if key not in ("skill_md_content",) and hasattr(skill, key):
|
||
setattr(skill, key, value)
|
||
|
||
skill.updated_at = datetime.now()
|
||
db.commit()
|
||
db.refresh(skill)
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="update_skill",
|
||
target="evolution_skills",
|
||
detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})",
|
||
)
|
||
|
||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||
|
||
|
||
# ============================================================
|
||
# 批量删除技能(用于一次性清空技能市场)
|
||
# ============================================================
|
||
|
||
|
||
class BatchDeleteSkillsRequest(BaseModel):
|
||
ids: list[str] = [] # 要删除的技能 ID 列表;传空列表则删除所有
|
||
|
||
|
||
@router.post("/batch-delete")
|
||
async def batch_delete_skills(
|
||
req: BatchDeleteSkillsRequest,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""批量删除技能。ids 为空则删除全部技能。"""
|
||
if req.ids:
|
||
skills = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.ids)).all()
|
||
else:
|
||
skills = db.query(EvolutionSkill).all()
|
||
|
||
deleted_count = 0
|
||
for skill in skills:
|
||
skill_name = skill.skill_name
|
||
# 删除版本历史
|
||
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
|
||
# 删除发布记录
|
||
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill.id).delete()
|
||
# 删除团队共享
|
||
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill.id).delete()
|
||
# 同步清理市场应用表中的技能
|
||
market_app = db.query(MarketApp).filter(
|
||
MarketApp.type == "skill",
|
||
MarketApp.name == skill_name,
|
||
).first()
|
||
if market_app:
|
||
# 清理关联的安装和购买记录
|
||
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
|
||
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
|
||
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
|
||
db.delete(market_app)
|
||
db.delete(skill)
|
||
deleted_count += 1
|
||
|
||
db.commit()
|
||
return {"code": 0, "data": {"deleted": deleted_count}, "message": f"已删除 {deleted_count} 个技能"}
|
||
|
||
|
||
# ============================================================
|
||
# 删除技能(单个)
|
||
# ============================================================
|
||
|
||
@router.delete("/{skill_id}")
|
||
async def delete_skill(
|
||
skill_id: str,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""删除技能(支持 EvolutionSkill 和 MarketApp 两种来源)"""
|
||
# 1) 先尝试 EvolutionSkill
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if skill:
|
||
skill_name = skill.skill_name
|
||
user_id = skill.user_id
|
||
|
||
# 记录事件
|
||
ev = EvolutionEvent(
|
||
id=str(uuid.uuid4()),
|
||
user_id=user_id,
|
||
skill_name=skill_name,
|
||
event_type="deleted",
|
||
description=f"管理员 {admin_user.username} 删除技能 {skill_name}",
|
||
)
|
||
db.add(ev)
|
||
|
||
# 删除版本历史
|
||
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
|
||
|
||
# 删除发布记录
|
||
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete()
|
||
|
||
# 删除团队共享记录
|
||
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete()
|
||
|
||
# 同步清理市场应用表中的同名技能
|
||
market_app = db.query(MarketApp).filter(
|
||
MarketApp.type == "skill",
|
||
MarketApp.name == skill_name,
|
||
).first()
|
||
if market_app:
|
||
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
|
||
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
|
||
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
|
||
db.delete(market_app)
|
||
|
||
db.delete(skill)
|
||
db.commit()
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="delete_skill",
|
||
target="evolution_skills",
|
||
detail=f"删除技能 {skill_name}",
|
||
)
|
||
|
||
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
|
||
|
||
# 2) 再尝试 MarketApp 中导入的技能(type=skill)
|
||
market_app = db.query(MarketApp).filter(
|
||
MarketApp.type == "skill",
|
||
MarketApp.id == skill_id,
|
||
).first()
|
||
if market_app:
|
||
skill_name = market_app.name
|
||
# 清理关联记录
|
||
db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete()
|
||
db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete()
|
||
db.query(AppReview).filter(AppReview.app_id == market_app.id).delete()
|
||
db.delete(market_app)
|
||
db.commit()
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="delete_skill",
|
||
target="market_apps",
|
||
detail=f"删除市场技能 {skill_name}",
|
||
)
|
||
|
||
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
|
||
|
||
# 3) 都未找到
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
|
||
# ============================================================
|
||
# 修改技能状态
|
||
# ============================================================
|
||
|
||
class ChangeStateSchema(BaseModel):
|
||
state: str = Field(..., description="目标状态: baby/main/staging/frozen")
|
||
|
||
|
||
@router.put("/{skill_id}/state")
|
||
async def change_skill_state(
|
||
skill_id: str,
|
||
req: ChangeStateSchema,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""修改技能状态"""
|
||
if req.state not in SKILL_STATES:
|
||
raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}")
|
||
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
old_state = skill.state
|
||
skill.state = req.state
|
||
skill.updated_at = datetime.now()
|
||
|
||
# 记录状态变更事件
|
||
ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen"
|
||
ev = EvolutionEvent(
|
||
id=str(uuid.uuid4()),
|
||
user_id=skill.user_id,
|
||
skill_name=skill.skill_name,
|
||
event_type=ev_type,
|
||
description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}",
|
||
)
|
||
db.add(ev)
|
||
db.commit()
|
||
db.refresh(skill)
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="change_skill_state",
|
||
target="evolution_skills",
|
||
detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}",
|
||
)
|
||
|
||
return {"code": 0, "data": skill_to_dict(skill)}
|
||
|
||
|
||
# ============================================================
|
||
# 获取版本历史
|
||
# ============================================================
|
||
|
||
@router.get("/{skill_id}/versions")
|
||
async def get_skill_versions(
|
||
skill_id: str,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取技能版本历史"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
versions = db.query(SkillVersion).filter(
|
||
SkillVersion.skill_name == skill.skill_name,
|
||
SkillVersion.user_id == skill.user_id,
|
||
).order_by(desc(SkillVersion.version)).all()
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"skill_id": skill.id,
|
||
"skill_name": skill.skill_name,
|
||
"current_version": skill.version,
|
||
"versions": [version_to_dict(v) for v in versions],
|
||
}
|
||
}
|
||
|
||
|
||
# ============================================================
|
||
# 回滚到指定版本
|
||
# ============================================================
|
||
|
||
class RollbackSchema(BaseModel):
|
||
version: int = Field(..., ge=1, description="目标版本号")
|
||
|
||
|
||
@router.post("/{skill_id}/rollback")
|
||
async def rollback_skill_version(
|
||
skill_id: str,
|
||
req: RollbackSchema,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""回滚技能到指定版本"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
target_version = db.query(SkillVersion).filter(
|
||
SkillVersion.skill_name == skill.skill_name,
|
||
SkillVersion.user_id == skill.user_id,
|
||
SkillVersion.version == req.version,
|
||
).first()
|
||
|
||
if not target_version:
|
||
raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在")
|
||
|
||
old_version = skill.version
|
||
|
||
# 更新 SKILL.md 文件
|
||
if target_version.skill_md_content:
|
||
if skill.skill_md_path:
|
||
try:
|
||
with open(skill.skill_md_path, 'w', encoding='utf-8') as f:
|
||
f.write(target_version.skill_md_content)
|
||
except Exception as e:
|
||
logger.warning(f"回滚保存 SKILL.md 失败: {e}")
|
||
|
||
# 记录新版本(基于目标版本)
|
||
new_version = old_version + 1
|
||
sv = SkillVersion(
|
||
id=str(uuid.uuid4()),
|
||
user_id=skill.user_id,
|
||
skill_name=skill.skill_name,
|
||
version=new_version,
|
||
skill_md_content=target_version.skill_md_content,
|
||
git_commit_sha=None,
|
||
description=f"回滚到版本 {req.version}(管理员操作)",
|
||
)
|
||
db.add(sv)
|
||
|
||
skill.version = new_version
|
||
skill.updated_at = datetime.now()
|
||
|
||
ev = EvolutionEvent(
|
||
id=str(uuid.uuid4()),
|
||
user_id=skill.user_id,
|
||
skill_name=skill.skill_name,
|
||
event_type="edited",
|
||
description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})",
|
||
version_before=old_version,
|
||
version_after=new_version,
|
||
)
|
||
db.add(ev)
|
||
|
||
db.commit()
|
||
db.refresh(skill)
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="rollback_skill",
|
||
target="evolution_skills",
|
||
detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})",
|
||
)
|
||
|
||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||
|
||
|
||
# ============================================================
|
||
# 发布到应用市场
|
||
# ============================================================
|
||
|
||
class PublishSkillSchema(BaseModel):
|
||
price: float = Field(0.0, ge=0, description="定价(0表示免费)")
|
||
market_description: str = Field("", description="市场描述")
|
||
|
||
|
||
@router.post("/{skill_id}/publish")
|
||
async def publish_skill(
|
||
skill_id: str,
|
||
req: PublishSkillSchema,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""发布技能到应用市场"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
# 检查是否已发布
|
||
existing = db.query(SkillPublishRecord).filter(
|
||
SkillPublishRecord.skill_id == skill_id,
|
||
SkillPublishRecord.status == "published"
|
||
).first()
|
||
|
||
if existing:
|
||
# 更新现有发布
|
||
existing.price = req.price
|
||
existing.market_description = req.market_description
|
||
msg = "更新发布信息"
|
||
else:
|
||
# 创建发布记录
|
||
record = SkillPublishRecord(
|
||
id=str(uuid.uuid4()),
|
||
skill_id=skill_id,
|
||
skill_name=skill.skill_name,
|
||
user_id=skill.user_id,
|
||
price=req.price,
|
||
market_description=req.market_description,
|
||
status="published",
|
||
)
|
||
db.add(record)
|
||
msg = "发布成功"
|
||
|
||
db.commit()
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="publish_skill",
|
||
target="skill_publish_records",
|
||
detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}",
|
||
)
|
||
|
||
return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}}
|
||
|
||
|
||
# ============================================================
|
||
# 下架技能
|
||
# ============================================================
|
||
|
||
@router.post("/{skill_id}/unpublish")
|
||
async def unpublish_skill(
|
||
skill_id: str,
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""从应用市场下架技能"""
|
||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||
if not skill:
|
||
raise HTTPException(status_code=404, detail="技能不存在")
|
||
|
||
updated = db.query(SkillPublishRecord).filter(
|
||
SkillPublishRecord.skill_id == skill_id,
|
||
SkillPublishRecord.status == "published"
|
||
).update({"status": "unpublished"})
|
||
db.commit()
|
||
|
||
if updated == 0:
|
||
raise HTTPException(status_code=400, detail="该技能未发布到市场")
|
||
|
||
LogService.log_operation(
|
||
user_id=admin_user.id,
|
||
username=admin_user.username,
|
||
action="unpublish_skill",
|
||
target="skill_publish_records",
|
||
detail=f"下架技能: {skill.skill_name}",
|
||
)
|
||
|
||
return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"}
|
||
|
||
|
||
# ============================================================
|
||
# 获取统计数据
|
||
# ============================================================
|
||
|
||
@router.get("/stats/overview")
|
||
async def get_skills_stats(
|
||
admin_user=Depends(get_current_admin),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""获取技能统计概览(EvolutionSkill + MarketApp type=skill 合并统计)"""
|
||
# EvolutionSkill 维度
|
||
evo_total = db.query(EvolutionSkill).count()
|
||
evo_by_state = dict(
|
||
db.query(EvolutionSkill.state, func.count(EvolutionSkill.id))
|
||
.group_by(EvolutionSkill.state).all()
|
||
)
|
||
evo_by_category = dict(
|
||
db.query(EvolutionSkill.category, func.count(EvolutionSkill.id))
|
||
.filter(EvolutionSkill.category.isnot(None))
|
||
.group_by(EvolutionSkill.category).all()
|
||
)
|
||
|
||
# MarketApp(type=skill) 维度:状态映射 published→main, 其它→baby
|
||
market_total = db.query(MarketApp).filter(MarketApp.type == "skill").count()
|
||
market_rows = db.query(MarketApp.status, func.count(MarketApp.id)).filter(
|
||
MarketApp.type == "skill"
|
||
).group_by(MarketApp.status).all()
|
||
market_published = sum(cnt for status, cnt in market_rows if (status or "") == "published")
|
||
# 各 category 计数(market 表 category 在 SQL 列有,ORM 无此属性,用 SQL 拿)
|
||
market_by_category = {}
|
||
try:
|
||
market_by_category_rows = db.execute(
|
||
text("SELECT capability, COUNT(*) FROM market_apps WHERE type='skill' AND capability IS NOT NULL GROUP BY capability")
|
||
).fetchall()
|
||
market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows}
|
||
except Exception:
|
||
pass
|
||
|
||
# 合并
|
||
total_skills = evo_total + market_total
|
||
by_state = {k: evo_by_state.get(k, 0) for k in SKILL_STATES}
|
||
by_state["main"] = by_state.get("main", 0) + market_published
|
||
by_state["baby"] = by_state.get("baby", 0) + (market_total - market_published)
|
||
by_category = {}
|
||
for c in (evo_by_category or {}).items():
|
||
by_category[c[0]] = by_category.get(c[0], 0) + c[1]
|
||
for c in (market_by_category or {}).items():
|
||
by_category[c[0]] = by_category.get(c[0], 0) + c[1]
|
||
|
||
# 原始发布记录 + market 已发布 = 全部已发布
|
||
skill_record_published = db.query(SkillPublishRecord).filter(
|
||
SkillPublishRecord.status == "published"
|
||
).count()
|
||
total_published = skill_record_published + market_published
|
||
total_versions = db.query(SkillVersion).count()
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"total_skills": total_skills,
|
||
"by_state": by_state,
|
||
"by_category": by_category,
|
||
"total_published": total_published,
|
||
"total_versions": total_versions,
|
||
}
|
||
}
|