1336 lines
57 KiB
Python
1336 lines
57 KiB
Python
from app.core.credential import encrypt_secret
|
||
from app.core.app_security import scan_market_app
|
||
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, MarketAppVersion
|
||
)
|
||
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 / markdown 规范的内容;仅拒绝整页 HTML 源码。
|
||
|
||
注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法
|
||
SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且
|
||
携带 markdown/skill 标记即接受。
|
||
"""
|
||
if not text:
|
||
return ""
|
||
t = text.strip()
|
||
if len(t) < 20:
|
||
return ""
|
||
low = t.lower()
|
||
# 拒绝整页 HTML 文档(避免把网页源码当技能内容写库)
|
||
if low.startswith("<!doctype") or low.startswith("<html"):
|
||
return ""
|
||
# 仅当明显是渲染后的 HTML 页面(含 script 且多 div 块)才拒绝;允许含 <script> 示例的 markdown
|
||
if "<script" in low and t.count("<div") > 3:
|
||
return ""
|
||
# 接受携带任一 markdown / skill 标记的内容
|
||
markers = ("#", "---", "name:", "description:", "```", "##")
|
||
if not any(m in t for m in markers):
|
||
return ""
|
||
return t
|
||
|
||
|
||
def _normalize_github_url(url: str) -> str:
|
||
"""GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。"""
|
||
u = (url or "").strip()
|
||
if "github.com" in u and ("/blob/" in u or "/tree/" in u):
|
||
m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u)
|
||
m = m.replace("/blob/", "/").replace("/tree/", "/")
|
||
return m
|
||
return u
|
||
|
||
|
||
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) <pre>/<code> 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 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换)
|
||
links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
|
||
seen = set()
|
||
for link in links[:5]:
|
||
if link in seen:
|
||
continue
|
||
seen.add(link)
|
||
try:
|
||
abs_url = urljoin(base_url, link)
|
||
# GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent
|
||
if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url:
|
||
abs_url = abs_url.replace("/blob/", "/raw/")
|
||
elif "github.com" in abs_url and "/blob/" in abs_url:
|
||
abs_url = _normalize_github_url(abs_url)
|
||
fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "")
|
||
with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
|
||
rr = c.get(fetch_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 ""
|
||
# GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一)
|
||
url = _normalize_github_url(url)
|
||
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 ""
|
||
|
||
|
||
def _category_guidance(cat: Optional[str]) -> str:
|
||
"""按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。"""
|
||
guides = {
|
||
"writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点",
|
||
"coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法",
|
||
"data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向",
|
||
"qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清",
|
||
"automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板",
|
||
"creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案",
|
||
"communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步",
|
||
"reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出",
|
||
}
|
||
return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案")
|
||
|
||
|
||
def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str:
|
||
"""当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md,
|
||
确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。"""
|
||
desc = (description or "").strip() or f"{name} 相关能力"
|
||
cat = category or "other"
|
||
cat_label_map = {
|
||
"writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析",
|
||
"qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成",
|
||
"communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力",
|
||
}
|
||
cat_label = cat_label_map.get(cat, cat)
|
||
guidance = _category_guidance(cat)
|
||
return (
|
||
f"---\n"
|
||
f"name: {name}\n"
|
||
f"description: {desc}\n"
|
||
f"category: {cat}\n"
|
||
f"---\n\n"
|
||
f"# {name}\n\n"
|
||
f"{desc}\n\n"
|
||
f"## 适用场景\n"
|
||
f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n"
|
||
f"## 执行指引\n"
|
||
f"{guidance}\n\n"
|
||
f"## 使用建议\n"
|
||
f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
|
||
f"- 输出结构清晰、可直接使用。\n"
|
||
f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
|
||
)
|
||
|
||
|
||
# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) =====
|
||
# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填,
|
||
# 也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。
|
||
|
||
# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。
|
||
_TAG_KEYWORDS = {
|
||
# 前端框架/库
|
||
"react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue",
|
||
"angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript",
|
||
"javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui",
|
||
"remotion": "Remotion", "expo": "Expo", "flutter": "Flutter",
|
||
"react native": "React Native", "vite": "Vite", "vercel": "Vercel",
|
||
# 后端/数据库
|
||
"node": "Node.js", "python": "Python", "postgres": "PostgreSQL",
|
||
"postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso",
|
||
"duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle",
|
||
"orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API",
|
||
"azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s",
|
||
"devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo",
|
||
# 测试/质量
|
||
"playwright": "Playwright", "cypress": "Cypress", "test": "测试",
|
||
"测试": "测试", "tdd": "TDD", "e2e": "E2E",
|
||
# 设计/视觉
|
||
"设计": "设计", "design": "设计", "ui": "UI", "ux": "UX",
|
||
"视觉": "视觉", "前端设计": "前端设计", "交互": "交互",
|
||
"图片生成": "图片生成", "ppt": "PPT", "排版": "排版",
|
||
# 写作/文档
|
||
"写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF",
|
||
"ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化",
|
||
# 营销/沟通
|
||
"营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件",
|
||
"email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容",
|
||
"策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM",
|
||
"上市": "上市", "心理学": "心理学",
|
||
# 推理/思维
|
||
"推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证",
|
||
"质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO",
|
||
# 自动化/流程
|
||
"自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程",
|
||
"发现": "技能发现", "执行计划": "执行计划",
|
||
# 数据
|
||
"数据分析": "数据分析", "分析": "数据分析",
|
||
# 内容生成/创意
|
||
"头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简",
|
||
# 代码相关
|
||
"代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构",
|
||
"组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模",
|
||
"抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现",
|
||
"数据获取": "数据获取", "移动端": "移动端", "移动": "移动端",
|
||
"全栈": "全栈", "后端": "后端", "前端": "前端",
|
||
}
|
||
|
||
# 分类 -> 中文基础标签(每个技能至少带一个分类标签)
|
||
_CATEGORY_TAG = {
|
||
"writing": "写作", "coding": "编程", "data_analysis": "数据分析",
|
||
"qa": "问答", "automation": "自动化", "creativity": "创意",
|
||
"communication": "沟通", "reasoning": "推理", "other": "通用",
|
||
}
|
||
|
||
# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确
|
||
_CATEGORY_NAME_OVERRIDES = {
|
||
"typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding",
|
||
"expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding",
|
||
"原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding",
|
||
"supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding",
|
||
"turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding",
|
||
"remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding",
|
||
"turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding",
|
||
"azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding",
|
||
"web 应用测试": "coding", "领域建模": "coding",
|
||
"web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity",
|
||
"高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity",
|
||
"极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity",
|
||
"移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity",
|
||
"前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity",
|
||
"prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing",
|
||
"写计划": "writing", "word 文档": "writing",
|
||
"营销心理学": "communication", "上市策略": "communication", "内容策略": "communication",
|
||
"社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication",
|
||
"想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning",
|
||
"完成前验证": "reasoning",
|
||
"技能发现": "automation", "playwright cli": "automation", "执行计划": "automation",
|
||
"duckdb 查询": "data_analysis",
|
||
}
|
||
|
||
# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级
|
||
_CATEGORY_KEYWORDS = {
|
||
"coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript",
|
||
"javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm",
|
||
"docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo",
|
||
"react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd",
|
||
"领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite",
|
||
"drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取",
|
||
"设计图转代码", "前端实现"],
|
||
"creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt",
|
||
"重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"],
|
||
"writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"],
|
||
"communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略",
|
||
"推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"],
|
||
"reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"],
|
||
"automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"],
|
||
"data_analysis": ["数据分析", "分析", "数据"],
|
||
"qa": ["问答", "知识问答", "搜索", "疑问"],
|
||
}
|
||
|
||
|
||
def _kw_hit(text: str, kw: str) -> bool:
|
||
"""关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。"""
|
||
if not kw:
|
||
return False
|
||
if kw.isascii():
|
||
return bool(re.search(r"(?<![a-z0-9])" + re.escape(kw) + r"(?![a-z0-9])", text))
|
||
return kw in text
|
||
|
||
|
||
def _auto_category(name: Optional[str], description: Optional[str] = None,
|
||
content: Optional[str] = None) -> str:
|
||
"""离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。"""
|
||
norm_name = (name or "").strip().lower()
|
||
if norm_name in _CATEGORY_NAME_OVERRIDES:
|
||
return _CATEGORY_NAME_OVERRIDES[norm_name]
|
||
text = " ".join([name or "", description or "", content or ""]).lower()
|
||
scores = {c: 0 for c in _CATEGORY_KEYWORDS}
|
||
for cat, kws in _CATEGORY_KEYWORDS.items():
|
||
for kw in kws:
|
||
if _kw_hit(text, kw):
|
||
scores[cat] += 1
|
||
best = max(scores, key=lambda c: scores[c])
|
||
return best if scores[best] > 0 else "other"
|
||
|
||
|
||
def _auto_tags(name: Optional[str], description: Optional[str] = None,
|
||
content: Optional[str] = None, category: Optional[str] = None) -> str:
|
||
"""离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。"""
|
||
text = " ".join([name or "", description or "", content or ""]).lower()
|
||
tags: list = []
|
||
seen = set()
|
||
# 1) 分类基础标签
|
||
if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen:
|
||
tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category])
|
||
# 2) 关键词派生标签
|
||
for kw, tag in _TAG_KEYWORDS.items():
|
||
if tag in seen:
|
||
continue
|
||
if _kw_hit(text, kw):
|
||
tags.append(tag); seen.add(tag)
|
||
return ",".join(tags[:6])
|
||
|
||
|
||
def _norm_name(name: Optional[str]) -> str:
|
||
"""技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。"""
|
||
return re.sub(r"\s+", " ", (name or "").strip().lower())
|
||
|
||
|
||
# ===== 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\": \"<id>\"}"
|
||
)
|
||
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'(?<![a-z])' + re.escape(sid) + r'(?![a-z])', text_out):
|
||
return sid
|
||
kw = s.get("kw", "")
|
||
if kw and kw in text_out:
|
||
return sid
|
||
return None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
class VerifyAgentRequest(BaseModel):
|
||
platform: Optional[str] = None
|
||
endpoint: str
|
||
api_key: str
|
||
|
||
|
||
@router.post("/apps/verify-agent")
|
||
def verify_agent(
|
||
req: VerifyAgentRequest,
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""接入 Agent 前手动测试连通性 / 鉴权,供前台「测试连接」按钮调用。"""
|
||
result = verify_agent_connectivity(req.platform or "dify", req.endpoint, req.api_key)
|
||
return {"code": 0, "data": result, "message": "ok"}
|
||
|
||
|
||
class DetectCapabilityRequest(BaseModel):
|
||
name: str
|
||
description: Optional[str] = None
|
||
skill_content: Optional[str] = None
|
||
type: Optional[str] = None
|
||
|
||
|
||
@router.post("/apps/detect-capability")
|
||
def detect_capability(
|
||
req: DetectCapabilityRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""导入前识别阶段:AI 识别业务场景(capability),离线规则法自动归类分类 + 自动打标签,
|
||
供前台预填并允许用户修改。"""
|
||
cap = _classify_scene(req.name, req.description, req.skill_content, db)
|
||
label = _scene_label(cap)
|
||
category = _auto_category(req.name, req.description, req.skill_content)
|
||
tags = _auto_tags(req.name, req.description, req.skill_content, category)
|
||
return {"code": 0, "data": {
|
||
"capability": cap, "label": label,
|
||
"category": category, "tags": tags,
|
||
}, "message": "ok"}
|
||
|
||
|
||
@router.get("/ads")
|
||
def list_public_ads(db: Session = Depends(get_db)):
|
||
"""
|
||
前台首页「精选推荐」广告位:返回已上架(active)广告,按排序升序,无需登录。
|
||
供 LandingPage 轮播调用,无鉴权依赖。
|
||
"""
|
||
ads = (
|
||
db.query(AdBanner)
|
||
.filter(AdBanner.status == "active")
|
||
.order_by(AdBanner.sort_order.asc())
|
||
.all()
|
||
)
|
||
return [
|
||
{
|
||
"id": a.id,
|
||
"title": a.title,
|
||
"image_url": a.image_url,
|
||
"link_app_id": a.link_app_id,
|
||
"link_app_name": a.link_app_name,
|
||
"sort_order": a.sort_order,
|
||
"click_count": a.click_count,
|
||
"status": a.status,
|
||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||
}
|
||
for a in ads
|
||
]
|
||
|
||
|
||
class UploadAppRequest(BaseModel):
|
||
name: str = Field(min_length=1, max_length=200)
|
||
description: Optional[str] = None
|
||
icon: Optional[str] = None
|
||
type: Optional[str] = None # skill, agent, mcp
|
||
source: Optional[str] = None
|
||
price: float = Field(default=0, ge=0)
|
||
download_url: Optional[str] = None
|
||
skill_content: Optional[str] = None
|
||
category: Optional[str] = None
|
||
capability: Optional[str] = None
|
||
tags: Optional[str] = None
|
||
is_member_free: Optional[bool] = False
|
||
join_promotion: Optional[bool] = False
|
||
agent_endpoint: Optional[str] = None
|
||
api_key: Optional[str] = None
|
||
platform: Optional[str] = None
|
||
dependencies: Optional[list] = None # Phase 4.1: 依赖声明
|
||
|
||
|
||
class PurchaseAppRequest(BaseModel):
|
||
amount: Optional[float] = None
|
||
|
||
|
||
class ReviewAppRequest(BaseModel):
|
||
rating: int = Field(ge=1, le=5)
|
||
content: Optional[str] = None
|
||
|
||
|
||
@router.get("/apps")
|
||
def list_apps(
|
||
type: Optional[str] = Query(None),
|
||
source: Optional[str] = Query(None),
|
||
capability: Optional[str] = Query(None),
|
||
search: Optional[str] = Query(None),
|
||
sort: str = Query(default="latest"),
|
||
page: int = Query(default=1, ge=1),
|
||
page_size: int = Query(default=20, ge=1, le=100),
|
||
db: Session = Depends(get_db),
|
||
user_id: Optional[str] = Depends(get_current_user_id_optional),
|
||
):
|
||
query = db.query(MarketApp).filter(MarketApp.status == "published")
|
||
|
||
if type:
|
||
query = query.filter(MarketApp.type == type)
|
||
if source:
|
||
query = query.filter(MarketApp.source == source)
|
||
if capability:
|
||
query = query.filter(MarketApp.capability == capability)
|
||
if search:
|
||
query = query.filter(
|
||
and_(
|
||
MarketApp.name.ilike(f"%{search}%")
|
||
| MarketApp.description.ilike(f"%{search}%")
|
||
)
|
||
)
|
||
|
||
if sort == "popular":
|
||
query = query.order_by(desc(MarketApp.install_count))
|
||
elif sort == "rating":
|
||
query = query.order_by(desc(MarketApp.rating_avg))
|
||
else:
|
||
query = query.order_by(desc(MarketApp.created_at))
|
||
|
||
total = query.count()
|
||
apps = query.offset((page - 1) * page_size).limit(page_size).all()
|
||
|
||
# 如果用户已登录,获取该用户已安装的应用ID集合(确保按用户隔离)
|
||
installed_app_ids = set()
|
||
if user_id:
|
||
installations = db.query(AppInstallation.app_id).filter(
|
||
AppInstallation.user_id == user_id
|
||
).all()
|
||
installed_app_ids = {inst[0] for inst in installations}
|
||
|
||
# 查询已安装状态时始终带 user_id 过滤
|
||
|
||
result = []
|
||
for app in apps:
|
||
# 用户自己创建/导入的应用应始终视为已安装(避免安装记录缺失导致显示未安装)
|
||
is_user_owned = bool(user_id) and app.source == "user" and app.publisher_id == user_id
|
||
item = {
|
||
"id": app.id,
|
||
"name": app.name,
|
||
"description": app.description,
|
||
"icon": app.icon,
|
||
"type": app.type,
|
||
"source": app.source,
|
||
"publisher_id": app.publisher_id,
|
||
"publisher_name": app.publisher_name,
|
||
"is_member_free": app.is_member_free if app.is_member_free else False,
|
||
"join_promotion": app.join_promotion if app.join_promotion else False,
|
||
"status": app.status,
|
||
"price": float(app.price) if app.price else 0,
|
||
"install_count": app.install_count or 0,
|
||
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
||
"review_count": app.review_count or 0,
|
||
"created_at": str(app.created_at) if app.created_at else None,
|
||
"capability": app.capability or None,
|
||
"category": app.category or None,
|
||
"tags": app.tags or None,
|
||
"installed": (app.id in installed_app_ids or is_user_owned) if user_id else False,
|
||
"version": app.version or "1.0.0",
|
||
"security_scan": app.security_scan,
|
||
"verified": bool(app.verified),
|
||
"dependencies": app.dependencies,
|
||
}
|
||
result.append(item)
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"total": total,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"items": result,
|
||
},
|
||
"message": "ok",
|
||
}
|
||
|
||
|
||
@router.get("/apps/{app_id}")
|
||
def get_app(
|
||
app_id: str,
|
||
db: Session = Depends(get_db),
|
||
current_user: Optional[User] = Depends(get_current_user),
|
||
):
|
||
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
|
||
if not app:
|
||
raise HTTPException(status_code=404, detail="应用不存在")
|
||
|
||
# 检查当前用户是否已购买(按用户隔离)
|
||
purchased = False
|
||
installed = False
|
||
if current_user:
|
||
purchase = db.query(AppPurchase).filter(
|
||
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
|
||
).first()
|
||
if purchase:
|
||
purchased = True
|
||
installed = purchase.status == "installed"
|
||
# 通过 AppInstallation 表检查是否已安装(按用户隔离)
|
||
installation = db.query(AppInstallation).filter(
|
||
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
|
||
).first()
|
||
if installation:
|
||
installed = True
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"id": app.id, "name": app.name, "description": app.description,
|
||
"icon": app.icon, "type": app.type, "source": app.source,
|
||
"type": app.type,
|
||
"price": float(app.price) if app.price else 0,
|
||
"install_count": app.install_count or 0,
|
||
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
|
||
"review_count": app.review_count or 0,
|
||
"publisher_id": app.publisher_id,
|
||
"download_url": getattr(app, "download_url", None),
|
||
"detail": getattr(app, "detail", None),
|
||
"capability": app.capability or None,
|
||
"category": app.category or None,
|
||
"tags": app.tags or None,
|
||
"created_at": str(app.created_at),
|
||
"purchased": purchased,
|
||
"installed": installed,
|
||
"version": app.version or "1.0.0",
|
||
"security_scan": app.security_scan,
|
||
"verified": bool(app.verified),
|
||
"dependencies": app.dependencies,
|
||
},
|
||
"message": "ok",
|
||
}
|
||
|
||
|
||
def _snapshot_version(app, db, created_by=None):
|
||
"""Phase 4.1: 写入一条应用版本快照(market_app_versions)。"""
|
||
snap = MarketAppVersion(
|
||
app_id=app.id,
|
||
version=app.version or "1.0.0",
|
||
type=app.type,
|
||
config={
|
||
"name": app.name,
|
||
"description": app.description,
|
||
"icon": app.icon,
|
||
"type": app.type,
|
||
"price": float(app.price) if app.price else 0,
|
||
"skill_content": app.skill_content,
|
||
"mcp_transport": app.mcp_transport,
|
||
"agent_endpoint": app.agent_endpoint,
|
||
"capability": app.capability,
|
||
"tool_policy": app.tool_policy,
|
||
"orchestration": app.orchestration,
|
||
},
|
||
security_scan=app.security_scan,
|
||
dependencies=app.dependencies,
|
||
created_by=created_by,
|
||
created_at=datetime.now(),
|
||
)
|
||
db.add(snap)
|
||
db.commit()
|
||
|
||
|
||
@router.post("/apps")
|
||
def upload_app(
|
||
req: UploadAppRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
# 接入 Agent 时先做连通性 / 鉴权校验,避免把无效连接存进库
|
||
if (req.type or "") == "agent" and req.agent_endpoint and req.api_key:
|
||
v = verify_agent_connectivity(req.platform or "dify", req.agent_endpoint, req.api_key)
|
||
if not v["ok"]:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Agent 连接校验未通过:{v['detail']}",
|
||
)
|
||
|
||
# source 判定:管理员(admin 角色)通过前台市场导入/开发的 Agent/Skill/MCP
|
||
# 统一标记为官方(official),其余用户按请求或默认为 user。
|
||
if current_user.role == "admin":
|
||
app_source = "official"
|
||
elif req.source and req.source in ("official", "user"):
|
||
app_source = req.source
|
||
else:
|
||
app_source = "user"
|
||
|
||
# 唯一性校验:同名(同类型)技能不允许重复导入,避免给平台徒增垃圾数据与运行压力(#690)
|
||
_app_type = req.type if req.type in ("skill", "agent", "mcp") else "skill"
|
||
_dup = db.query(MarketApp).filter(
|
||
func.lower(MarketApp.name) == _norm_name(req.name),
|
||
MarketApp.type == _app_type,
|
||
).first()
|
||
if _dup:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"技能「{req.name}」已存在,无需重复导入(平台已有同名技能,ID: {_dup.id})",
|
||
)
|
||
|
||
# 自动分类 + 自动打标签:前端未显式指定时由离线规则法兜底(#690)
|
||
_classify_content = req.skill_content or req.download_url or ""
|
||
_final_category = req.category or _auto_category(req.name, req.description, _classify_content)
|
||
_final_tags = req.tags or _auto_tags(req.name, req.description, _classify_content, _final_category)
|
||
|
||
app = MarketApp(
|
||
id=gen_uuid(),
|
||
name=req.name,
|
||
description=req.description,
|
||
icon=req.icon,
|
||
type=_app_type,
|
||
source=app_source,
|
||
category=_final_category, # 持久化分类(#679 修复:此前未落库导致分类丢失)
|
||
tags=_final_tags,
|
||
platform=req.platform,
|
||
price=req.price,
|
||
install_count=0,
|
||
rating_avg=0,
|
||
review_count=0,
|
||
publisher_id=current_user.id,
|
||
publisher_name=current_user.username,
|
||
is_member_free=req.is_member_free if req.is_member_free is not None else False,
|
||
join_promotion=req.join_promotion if req.join_promotion is not None else False,
|
||
agent_endpoint=req.agent_endpoint,
|
||
agent_api_key=encrypt_secret(req.api_key),
|
||
status="published", # 用户创建的应用直接发布
|
||
created_at=datetime.now(),
|
||
updated_at=datetime.now(),
|
||
)
|
||
# 能力分类:前端显式指定优先;否则导入阶段由 AI 自动归类;都不行则留空(不阻断导入)
|
||
app.capability = req.capability or _classify_scene(req.name, req.description, _classify_content, db) or None
|
||
db.add(app)
|
||
db.commit()
|
||
db.refresh(app)
|
||
|
||
# MCP 应用同步注册到后台 MCP Client 管理(mcp_servers 表)
|
||
if app.type == "mcp":
|
||
code = req.download_url or ""
|
||
if code:
|
||
app.skill_content = code
|
||
parsed = parse_mcp_config(code)
|
||
transport = parsed["transport_type"]
|
||
app.mcp_transport = transport
|
||
db.commit()
|
||
db.refresh(app)
|
||
|
||
existing_server = db.query(MCPServer).filter(MCPServer.id == app.id).first()
|
||
if not existing_server:
|
||
url = parsed["url"] or _extract_url_from_text(req.description or "") or ""
|
||
headers_json = json.dumps(parsed["headers"], ensure_ascii=False) if parsed["headers"] else "{}"
|
||
server = MCPServer(
|
||
id=app.id,
|
||
name=app.name,
|
||
description=app.description or "",
|
||
transport_type=transport,
|
||
command="",
|
||
args="{}",
|
||
env_vars="{}",
|
||
url=url,
|
||
headers=headers_json,
|
||
is_enabled=True,
|
||
status="disconnected",
|
||
tools_count=0,
|
||
resources_count=0,
|
||
created_at=app.created_at or datetime.now(),
|
||
updated_at=app.updated_at or datetime.now(),
|
||
)
|
||
db.add(server)
|
||
db.commit()
|
||
|
||
# 导入后自动做一次连通性测试,状态会同步为 connected / error
|
||
_test_mcp_server(server, db)
|
||
|
||
# Skill / Agent:保存真实技能定义内容,否则对话里读不到 skill_content → 导入成功但失效
|
||
if app.type == "skill":
|
||
content = req.skill_content or ""
|
||
if not content and req.download_url:
|
||
# Hub 导入:按目录 URL 抓取真实 SKILL.md 内容回填
|
||
content = _fetch_skill_content(req.download_url) or ""
|
||
if not content:
|
||
# 兜底:抓取/粘贴均未提供内容时,基于名称/描述/分类生成可用 SKILL.md,
|
||
# 避免空壳技能(#681 根因修复:保证再次导入也不会内容为空)
|
||
content = _generate_skill_content(app.name, app.description, req.category, app.capability)
|
||
app.skill_content = content
|
||
db.commit()
|
||
elif app.type == "agent":
|
||
if req.skill_content:
|
||
app.skill_content = req.skill_content
|
||
db.commit()
|
||
|
||
# 用户创建/导入的应用自动标记为已安装
|
||
existing_install = db.query(AppInstallation).filter(
|
||
AppInstallation.user_id == current_user.id,
|
||
AppInstallation.app_id == app.id,
|
||
).first()
|
||
if not existing_install:
|
||
installation = AppInstallation(
|
||
user_id=current_user.id,
|
||
app_id=app.id,
|
||
installed_at=datetime.now(),
|
||
)
|
||
db.add(installation)
|
||
db.commit()
|
||
|
||
# Phase 4.1: 创建即安全扫描 + 初始版本快照 + 绿标(仅 pass 自动给)
|
||
scan_result = scan_market_app(
|
||
app_type=app.type,
|
||
skill_content=app.skill_content,
|
||
agent_endpoint=app.agent_endpoint,
|
||
mcp_transport=app.mcp_transport,
|
||
mcp_config=app.skill_content if app.type == "mcp" else None,
|
||
dependencies=req.dependencies,
|
||
)
|
||
app.version = "1.0.0"
|
||
app.security_scan = scan_result
|
||
app.dependencies = req.dependencies
|
||
app.verified = (scan_result.get("status") == "pass")
|
||
db.commit()
|
||
db.refresh(app)
|
||
_snapshot_version(app, db, current_user.id)
|
||
|
||
LogService.log_operation(
|
||
user_id=current_user.id, username=current_user.username, action="upload_app",
|
||
target="market_app", target_id=app.id,
|
||
detail=f"上传应用 {app.name}(安检 {scan_result.get('status')})",
|
||
)
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"id": app.id, "name": app.name, "status": app.status,
|
||
"capability": app.capability,
|
||
"created_at": str(app.created_at),
|
||
},
|
||
"message": "提交成功,等待审核",
|
||
}
|
||
|
||
|
||
@router.post("/apps/{app_id}/purchase")
|
||
def purchase_app(
|
||
app_id: str,
|
||
req: PurchaseAppRequest,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
app = db.query(MarketApp).filter(
|
||
and_(MarketApp.id == app_id, MarketApp.status == "published")
|
||
).first()
|
||
if not app:
|
||
raise HTTPException(status_code=404, detail="应用不存在或未上架")
|
||
|
||
# 检查是否已购买
|
||
existing = db.query(AppPurchase).filter(
|
||
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
|
||
).first()
|
||
if existing and existing.status in ("purchased", "installed"):
|
||
raise HTTPException(status_code=400, detail="您已购买该应用")
|
||
|
||
amount = req.amount or app.price
|
||
|
||
# 创建订单
|
||
order = Order(
|
||
id=gen_uuid(),
|
||
user_id=current_user.id,
|
||
order_type="app",
|
||
item_id=app_id,
|
||
item_name=app.name,
|
||
amount=amount,
|
||
status="pending",
|
||
created_at=datetime.now(),
|
||
)
|
||
db.add(order)
|
||
db.flush()
|
||
|
||
# 创建购买记录
|
||
purchase = AppPurchase(
|
||
id=gen_uuid(),
|
||
user_id=current_user.id,
|
||
app_id=app_id,
|
||
order_id=order.id,
|
||
status="purchased",
|
||
purchased_at=datetime.now(),
|
||
)
|
||
db.add(purchase)
|
||
|
||
# 更新应用下载量
|
||
app.install_count = (app.install_count or 0) + 1
|
||
app.updated_at = datetime.now()
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"code": 0,
|
||
"data": {
|
||
"order_id": order.id,
|
||
"purchase_id": purchase.id,
|
||
"amount": amount,
|
||
"status": "purchased",
|
||
},
|
||
"message": "购买成功",
|
||
}
|
||
|
||
|
||
@router.post("/apps/{app_id}/install")
|
||
def install_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="应用不存在")
|
||
|
||
# 检查是否已安装(通过 AppInstallation 表)
|
||
existing_install = db.query(AppInstallation).filter(
|
||
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
|
||
).first()
|
||
if existing_install:
|
||
raise HTTPException(status_code=400, detail="应用已安装")
|
||
|
||
# 检查是否已购买(付费应用需要先购买)
|
||
purchase = db.query(AppPurchase).filter(
|
||
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
|
||
).first()
|
||
|
||
# 对于付费应用,必须已购买才能安装
|
||
if float(app.price or 0) > 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",
|
||
}
|
||
|