from app.core.credential import encrypt_secret from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from sqlalchemy import and_, desc, asc, func from pydantic import BaseModel, Field from typing import Optional import uuid from datetime import datetime import httpx from urllib.parse import urlparse, urljoin import re import json import os from ..database import get_db from ..models import ( User, MarketApp, AppPurchase, AppReview, AppInstallation, Order, AdBanner, MCPServer, ModelProvider, gen_uuid ) from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional from ..middleware.log_service import LogService from ..capability_registry import resolve_auto_model as _resolve_auto_model from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config router = APIRouter() def _extract_url_from_text(text: Optional[str]) -> Optional[str]: """Extract first http/https URL from text.""" if not text: return None match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text) return match.group(0) if match else None def _skill_http_proxy(): """容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。""" return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None def _sanitize_skill_content(text: str) -> str: """仅当内容疑似有效 SKILL.md 时才接受,避免把无关网页/乱码写进 skill_content。""" if not text: return "" t = text.strip() if len(t) < 50: return "" low = t.lower() # 拒绝整页 HTML(避免把网页源码当技能内容写库) if low.startswith(" 3: return "" # 接受:YAML front matter 或 含 markdown 标题(# / ##) has_front = t.startswith("---") and ("name:" in t or "description:" in t) has_heading = t.startswith("#") or "\n#" in t or "##" in t if not (has_front or has_heading): return "" return t def _extract_skill_from_html(html: str, base_url: str) -> str: """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。""" proxy = _skill_http_proxy() # 1) fenced code block fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE) for f in fences: if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80: return f.strip() # 2)
/ blocks
for tag in (r"pre", r"code"):
for b in re.findall(r"<%s[^>]*>(.*?)%s>" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
txt = re.sub(r"<[^>]+>", "", b)
if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
return txt.strip()
# 3) 跳转到 raw SKILL.md 链接(最多试 3 个)
links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
for link in links[:3]:
try:
abs_url = urljoin(base_url, link)
with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
rr = c.get(abs_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if rr.status_code == 200 and len(rr.text) > 80:
return rr.text.strip()
except Exception:
continue
return ""
def _fetch_skill_content(url: str) -> str:
"""抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
if not url or not url.startswith("http"):
return ""
proxy = _skill_http_proxy()
try:
with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
if r.status_code != 200:
return ""
raw = r.text
ctype = (r.headers.get("content-type") or "").lower()
u = url.rstrip("/")
if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
return _sanitize_skill_content(raw)
return _sanitize_skill_content(_extract_skill_from_html(raw, url))
except Exception:
return ""
# ===== Agent 接入前连通性 / 鉴权校验 =====
def _normalize_dify_url(endpoint: str) -> str:
ep = endpoint.rstrip("/")
if ep.endswith("/chat-messages"):
return ep
if ep.endswith("/v1/chat-messages"):
return ep
if ep.endswith("/v1"):
return ep + "/chat-messages"
return ep + "/v1/chat-messages"
def _short_err(e: Exception) -> str:
return str(e)[:160]
def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
"""POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。
背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
/v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
容器内 env 有坏代理(502),必须 trust_env=False 直连。
"""
cur = url
last = None
for _ in range(4):
last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
trust_env=False, follow_redirects=False)
if last.status_code in (301, 302, 307, 308):
loc = last.headers.get("location")
if not loc:
return last
cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
continue
return last
return last
def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
"""
接入 Agent 前的连通性 + 鉴权校验。
返回 {ok, verified, detail}
ok: 是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
"""
ep = (endpoint or "").strip()
if not ep or not ep.startswith(("http://", "https://")):
return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
platform = (platform or "dify").lower()
# 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
if platform == "dify":
url = _normalize_dify_url(ep)
payload = {
"inputs": {},
"query": "连通性测试",
"user": "__verify__",
"response_mode": "blocking",
}
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
try:
r = _post_follow(url, headers, payload, timeout=15.0)
except Exception as e:
return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
if r.status_code == 200:
return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
if r.status_code in (401, 403):
return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
if r.status_code == 404:
return {"ok": False, "verified": False,
"detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
if r.status_code in (301, 302, 307, 308):
loc = r.headers.get("location") or ""
return {"ok": False, "verified": False,
"detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
if r.status_code == 400:
# 400 需区分两种情况:
# (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
# (b) 纯参数错误
body = (r.text or "")
low = body.lower()
if ("run failed" in low or "error code" in low or "workflow" in low
or "node" in low or "-500" in body):
return {"ok": True, "verified": True,
"detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
"(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
# n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
try:
r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
if r.status_code < 500:
return {"ok": True, "verified": False,
"detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
except Exception as e:
return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
# ===== 业务场景分类(面向用户的技能/MCP 分类,与后端模型路由的 capability_registry 解耦) =====
# 注意:MarketApp.capability 列在此存放「业务场景 id」(见 MARKET_SCENES),不再是 text/vision 等模型能力。
MARKET_SCENES: list = [
# 元宝生成的「岗位 / 职能」分类(面向使用者视角,与后端模型路由能力解耦)
{"id": "design", "label": "🎨 做设计", "color": "#EC4899", "desc": "页面、UI、视觉", "kw": "做设计"},
{"id": "frontend", "label": "💻 写前端", "color": "#3B82F6", "desc": "Web 页面、React、Next.js", "kw": "写前端"},
{"id": "mobile", "label": "📱 做移动端", "color": "#06B6D4", "desc": "React Native、Expo", "kw": "做移动端"},
{"id": "backend", "label": "🧪 后端/全栈", "color": "#8B5CF6", "desc": "API、数据库、TypeScript", "kw": "后端"},
{"id": "qa", "label": "🧪 测试质量", "color": "#6366F1", "desc": "TDD、Playwright、验证", "kw": "测试"},
{"id": "search", "label": "🔍 搜索信息", "color": "#0EA5E9", "desc": "网页抓取、质疑、SEO", "kw": "搜索"},
{"id": "docs", "label": "✍️ 写文档", "color": "#F59E0B", "desc": "Word/PPT/PDF、文案、PRD", "kw": "写文档"},
{"id": "marketing", "label": "📢 推广获客", "color": "#F43F5E", "desc": "营销、社媒、邮件、定价", "kw": "推广"},
{"id": "workflow", "label": "🔄 工作流", "color": "#14B8A6", "desc": "计划、调试、分级、原型", "kw": "工作流"},
{"id": "secops", "label": "🛡️ 安全运维", "color": "#EF4444", "desc": "安全审计、Azure、K8s", "kw": "安全"},
{"id": "learning", "label": "🎓 学习教学", "color": "#10B981", "desc": "教学、提问、架构改进", "kw": "学习"},
{"id": "video", "label": "🎬 视频多媒体", "color": "#D946EF", "desc": "AI视频、数字人", "kw": "视频"},
{"id": "collab", "label": "🏢 办公协作", "color": "#64748B", "desc": "飞书全家桶", "kw": "办公"},
{"id": "ai", "label": "🛠️ AI能力", "color": "#A855F7", "desc": "创建Skill、代码审查、统计", "kw": "AI能力"},
]
_VALID_SCENE_IDS = {s["id"] for s in MARKET_SCENES}
def _scene_label(scene_id):
return next((s["label"] for s in MARKET_SCENES if s["id"] == scene_id), None)
def _classify_scene(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
"""用平台文本模型把应用归类到面向用户的业务场景之一;失败返回 None(不阻断导入)。"""
try:
text_model = _resolve_auto_model("text", db)
if not text_model:
return None
provider = None
if getattr(text_model, "provider_id", None):
provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
api_key = text_model.api_key or (provider.api_key if provider else None)
if not api_key:
return None
api_url = text_model.endpoint or (
f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
)
if not api_url:
return None
options = "、".join(f"{s['id']}({s['label']}:{s.get('desc','')})" for s in MARKET_SCENES)
sys_prompt = (
"你是 AI 应用市场的『技能场景分类器』。请站在使用者视角,根据应用名称、描述与内容,"
"判断它最贴合下列哪一类【岗位 / 职能 / 业务场景】,并只输出对应英文 id:"
f"{options}。\n必须只返回如下 JSON,且 capability 的值只能是上面列出的英文 id(小写),"
"不要返回中文标签、不要任何解释或 Markdown 代码块:{\"capability\": \"\"}"
)
user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": user_text},
]
proxy = _skill_http_proxy()
with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
r = c.post(
api_url,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
)
if r.status_code != 200:
return None
data = r.json()
text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
# 主解析:标准 JSON 的 capability 字段
m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
if m and m.group(1) in _VALID_SCENE_IDS:
return m.group(1)
# 兜底:在输出中扫描任一合法 id 或中文关键词(容错标签/未加引号/多余文本等情况)
for s in MARKET_SCENES:
sid = s["id"]
if re.search(r'(? 0:
if not purchase:
raise HTTPException(status_code=403, detail="请先购买应用")
if purchase.status == "installed":
raise HTTPException(status_code=400, detail="应用已安装")
# 创建安装记录
installation = AppInstallation(
id=gen_uuid(),
user_id=current_user.id,
app_id=app_id,
installed_at=datetime.now(),
)
db.add(installation)
# 如果有购买记录,更新状态为已安装
if purchase:
purchase.status = "installed"
purchase.installed_at = datetime.now()
# 更新应用安装量
app.install_count = (app.install_count or 0) + 1
app.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": {"installation_id": installation.id, "status": "installed"},
"message": "安装成功",
}
@router.delete("/apps/{app_id}/uninstall")
def uninstall_app(
app_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""卸载应用 - 删除当前用户的安装记录(按用户隔离)"""
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 查找当前用户的安装记录(按用户隔离)
installation = db.query(AppInstallation).filter(
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
).first()
if not installation:
raise HTTPException(status_code=400, detail="应用未安装")
# 删除安装记录
db.delete(installation)
# 问题4修复: 将AppPurchase状态从installed重置为purchased
purchase = db.query(AppPurchase).filter(
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
).first()
if purchase and purchase.status == "installed":
purchase.status = "purchased"
# 更新应用安装量(减1,最小为0)
if app.install_count and app.install_count > 0:
app.install_count = app.install_count - 1
app.updated_at = datetime.now()
db.commit()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="uninstall_app",
target="market_app", target_id=app_id,
detail=f"卸载应用 {app.name}",
)
return {
"code": 0,
"message": "卸载成功",
}
@router.post("/apps/{app_id}/review")
def review_app(
app_id: str,
req: ReviewAppRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否已评价
existing = db.query(AppReview).filter(
and_(AppReview.app_id == app_id, AppReview.user_id == current_user.id)
).first()
if existing:
# 更新已有评价
old_rating = existing.rating
existing.rating = req.rating
existing.content = req.content
existing.updated_at = datetime.now()
# 重新计算平均分 (总数不变,减去旧评分加新评分)
if app.review_count and app.review_count > 0:
total_rating = float(app.rating_avg or 0) * app.review_count
total_rating = total_rating - old_rating + req.rating
app.rating_avg = total_rating / app.review_count
else:
app.rating_avg = req.rating
app.review_count = 1
else:
# 创建新评价
review = AppReview(
id=gen_uuid(),
app_id=app_id,
user_id=current_user.id,
rating=req.rating,
content=req.content,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(review)
# 重新计算平均分
app.review_count = (app.review_count or 0) + 1
if app.review_count == 1:
app.rating_avg = req.rating
else:
total_rating = float(app.rating_avg or 0) * (app.review_count - 1) + req.rating
app.rating_avg = round(total_rating / app.review_count, 1)
app.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": {
"rating_avg": float(app.rating_avg or 0),
"review_count": app.review_count or 0,
},
"message": "评价成功",
}
@router.get("/my-apps")
def get_my_apps(
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(AppPurchase).filter(AppPurchase.user_id == current_user.id)
purchases = query.order_by(desc(AppPurchase.created_at)).all()
result = []
for p in purchases:
app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first()
if app:
result.append({
"id": p.id,
"app_id": app.id,
"name": app.name,
"icon": app.icon,
"type": app.type,
"price": float(app.price or 0),
"purchased_at": str(p.created_at) if p.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.get("/reviews/{app_id}")
def get_reviews(
app_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
):
query = db.query(AppReview).filter(AppReview.app_id == app_id)
total = query.count()
reviews = (
query.order_by(desc(AppReview.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for r in reviews:
user = db.query(User).filter(User.id == r.user_id).first()
result.append({
"id": r.id,
"user_id": r.user_id,
"username": user.username if user else "unknown",
"avatar": user.avatar if user else None,
"rating": r.rating,
"content": r.content,
"created_at": str(r.created_at) if r.created_at else None,
})
return {
"code": 0,
"data": {"total": total, "page": page, "page_size": page_size, "items": result},
"message": "ok",
}
# ============================================================
# Agent部署资源查询接口
# ============================================================
@router.get("/agent-resources")
def get_agent_resources(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Agent部署时需要的资源列表:可用的应用、知识库、文档"""
# 用户已发布的应用
my_apps = db.query(MarketApp).filter(
MarketApp.publisher_id == current_user.id,
MarketApp.status == "published",
).order_by(desc(MarketApp.updated_at)).all()
apps_result = []
for app in my_apps:
apps_result.append({
"id": app.id,
"name": app.name,
"type": app.type,
"description": app.description,
"install_count": app.install_count or 0,
})
# 用户知识库
from ..models import KnowledgeBase, KnowledgeDocument
kb_list = db.query(KnowledgeBase).filter(
KnowledgeBase.user_id == current_user.id
).order_by(desc(KnowledgeBase.updated_at)).all()
kb_result = []
for kb in kb_list:
doc_count = db.query(KnowledgeDocument).filter(
KnowledgeDocument.knowledge_base_id == kb.id
).count()
kb_result.append({
"id": kb.id,
"name": kb.name,
"description": kb.description,
"document_count": doc_count,
})
# 用户已安装的应用(从AppPurchase)
installed = db.query(AppPurchase).filter(
AppPurchase.user_id == current_user.id
).all()
installed_ids = [p.app_id for p in installed]
installed_apps = []
if installed_ids:
iapps = db.query(MarketApp).filter(MarketApp.id.in_(installed_ids)).all()
for ia in iapps:
installed_apps.append({
"id": ia.id,
"name": ia.name,
"type": ia.type,
"description": ia.description,
})
return {
"code": 0,
"data": {
"my_apps": apps_result,
"knowledge_bases": kb_result,
"installed_apps": installed_apps,
},
"message": "ok",
}