""" 技能管理系统 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_ 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": "已停止使用"}, } SKILL_CATEGORIES = [ {"id": "writing", "label": "文字创作"}, {"id": "coding", "label": "代码能力"}, {"id": "data_analysis", "label": "数据分析"}, {"id": "qa", "label": "知识问答"}, {"id": "automation", "label": "流程自动化"}, {"id": "creativity", "label": "创意生成"}, {"id": "communication", "label": "沟通协作"}, {"id": "reasoning", "label": "逻辑推理"}, {"id": "other", "label": "其他"}, ] # ============================================================ # 辅助函数 # ============================================================ 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), ): """获取全平台技能列表(支持分页/过滤/搜索)""" logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}") query = db.query(EvolutionSkill) if state: query = query.filter(EvolutionSkill.state == state) if category: query = query.filter(EvolutionSkill.category == category) if keyword: query = query.filter( or_( EvolutionSkill.skill_name.contains(keyword), EvolutionSkill.description.contains(keyword) ) ) if user_id: query = query.filter(EvolutionSkill.user_id == user_id) # 总数 total = query.count() # 排序 sort_field = getattr(EvolutionSkill, sort_by, EvolutionSkill.updated_at) if order == "desc": query = query.order_by(desc(sort_field)) else: query = query.order_by(sort_field) # 分页 skills = query.offset((page - 1) * page_size).limit(page_size).all() # 补充用户名 user_ids = list(set(s.user_id for s in skills)) users_map = { u.id: u.username for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all() } result = [] for s in skills: item = skill_to_dict(s) item["owner_username"] = users_map.get(s.user_id, s.user_id) result.append(item) return { "code": 0, "data": { "items": result, "total": total, "page": page, "page_size": page_size, "pages": (total + page_size - 1) // page_size, } } # ============================================================ # 获取单个技能详情 # ============================================================ @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), ): """删除技能""" skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() if not skill: raise HTTPException(status_code=404, detail="技能不存在") 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() 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} 已删除"} # ============================================================ # 修改技能状态 # ============================================================ 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), ): """获取技能统计概览""" total = db.query(EvolutionSkill).count() by_state = dict( db.query(EvolutionSkill.state, func.count(EvolutionSkill.id)) .group_by(EvolutionSkill.state).all() ) by_category = dict( db.query(EvolutionSkill.category, func.count(EvolutionSkill.id)) .filter(EvolutionSkill.category.isnot(None)) .group_by(EvolutionSkill.category).all() ) total_published = db.query(SkillPublishRecord).filter( SkillPublishRecord.status == "published" ).count() total_versions = db.query(SkillVersion).count() return { "code": 0, "data": { "total_skills": total, "by_state": {k: by_state.get(k, 0) for k in SKILL_STATES}, "by_category": by_category, "total_published": total_published, "total_versions": total_versions, } }