diff --git a/.claude/commands/discover-projects.md b/.claude/commands/discover-projects.md new file mode 100644 index 0000000..8464312 --- /dev/null +++ b/.claude/commands/discover-projects.md @@ -0,0 +1,95 @@ +--- +description: 执行项目发现探索任务,按内容标准模板探索并创建高质量项目 +--- + +## 用户输入 + +```text +$ARGUMENTS +``` + +在继续之前,你**必须**考虑用户输入(如果不为空)。 + +## 概述 + +1. 解析用户输入的任务数量参数(默认10个) +2. 从生产环境API获取PENDING状态的探索任务 +3. 读取项目内容标准模板(PROJECT_CONTENT_STANDARD.md) +4. 并行更新任务状态为IN_PROGRESS +5. 使用agent-browser并行探索项目 +6. 按内容标准模板整理探索结果 +7. 调用完成API创建项目 +8. 报告处理结果 + +## 执行步骤 + +### 步骤1:获取待处理任务 + +```bash +# 从生产环境API获取PENDING任务 +curl -X GET "https://agentpark.fun/api/discovery/tasks?status=PENDING&limit=${TASK_COUNT}" +``` + +如果没有任务,显示提示信息并退出。 + +### 步骤2:读取内容标准模板 + +读取 `PROJECT_CONTENT_STANDARD.md` 文件,理解内容要求。 + +### 步骤3:并行探索项目 + +对于每个任务,使用agent-browser探索: + +1. 导航到项目URL +2. 提取README内容 +3. 如果是GitHub项目,获取仓库信息 +4. 按内容标准模板整理信息 +5. 生成符合ProjectInputSchema的数据结构 + +并发控制:同时处理3个任务。 + +### 步骤4:创建项目 + +对每个探索成功的任务,调用生产环境API: +```bash +curl -X POST "https://agentpark.fun/api/discovery/tasks/${TASK_ID}/complete" \ + -H "Content-Type: application/json" \ + -d '{ + "apiKey": "'"${API_KEY}"'", + "explorationData": { ... } + }' +``` + +### 步骤5:错误处理 + +失败的任务: +- 自动重试(最多3次) +- 使用指数退避策略 +- 超过重试次数后标记为FAILED + +### 步骤6:报告结果 + +显示处理统计: +- 成功创建的项目数 +- 失败的任务数 +- 处理耗时 + +## 使用示例 + +```bash +# 处理默认10个任务 +/discover-projects + +# 处理指定数量的任务 +/discover-projects 5 + +# 处理所有待处理任务 +/discover-projects all +``` + +## 注意事项 + +- 使用生产环境API:https://agentpark.fun +- API密钥从环境变量WEBHOOK_API_KEY获取 +- 探索质量严格按照PROJECT_CONTENT_STANDARD.md执行 +- 所有操作直接在生产数据库上进行 diff --git a/.claude/settings.json b/.claude/settings.json index ede3347..9e26dfe 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1 @@ -// { -// "env": { -// "HTTP_PROXY": "http://proxy3.bj.petrochina:8080", -// "HTTPS_PROXY": "http://proxy3.bj.petrochina:8080" -// } -// } +{} \ No newline at end of file diff --git a/PROJECT_CONTENT_STANDARD.md b/PROJECT_CONTENT_STANDARD.md new file mode 100644 index 0000000..b779047 --- /dev/null +++ b/PROJECT_CONTENT_STANDARD.md @@ -0,0 +1,110 @@ +# 项目内容标准模板 + +此文档定义了项目探索时需要收集的内容结构和质量标准。 + +## 必需内容 + +### 1. 基本信息 +- **中文名称**:项目的中文名称(如果原项目是英文,需要翻译) +- **英文名称**:原始项目名称(保持原文) +- **中文描述**:1-2句话概括项目功能(10-500字) +- **英文描述**:项目功能的英文描述 + +### 2. 详细内容(Markdown格式) +- **项目简介**:背景、目的、解决的问题 +- **核心功能**:主要功能列表或说明 +- **技术栈**:使用的技术、框架、工具 +- **使用场景**:适用场景和用例 +- **特点亮点**:与同类项目的差异化特点 + +### 3. 标签(1-10个) +- 技术标签:如 `NLP`, `Computer Vision`, `React` +- 应用标签:如 `聊天机器人`, `数据分析` +- 状态标签:如 `活跃维护`, `实验性项目` + +### 4. 外部链接(至少1个) +- **GitHub链接**:如果项目在GitHub上 +- **官网链接**:项目官方网站或文档 +- **Demo链接**:在线演示地址 +- **论文链接**:如果有相关论文 + +## 内容质量标准 + +### 描述质量 +- ✅ 清晰说明项目是做什么的 +- ✅ 突出项目的核心价值 +- ✅ 避免营销术语,保持客观 +- ❌ 避免直接复制README第一句 + +### 内容完整性 +- ✅ 从README中提取关键信息 +- ✅ 重新组织内容,使其更易读 +- ✅ 添加必要的上下文说明 +- ❌ 不要机械翻译,要符合中文表达习惯 + +### 链接有效性 +- ✅ 所有链接都必须可访问 +- ✅ 优先使用GitHub仓库链接 +- ✅ 包含文档或Demo链接 + +## 动态数据处理 + +以下数据**不应**写入内容中(使用GitHub Badge显示): +- Star/Fork数量 +- 最近更新时间 +- 贡献者数量 +- Issue/PR数量 + +## 内容来源优先级 + +1. **README.md** - 主要信息来源 +2. **官网/文档** - 补充说明 +3. **代码结构** - 理解技术实现 +4. **Issues/Discussions** - 了解用户反馈 + +## 示例格式 + +### 简洁版(适用于小型项目) +```markdown +## 简介 +[1-2句话说明项目] + +## 功能 +- 功能1 +- 功能2 + +## 技术栈 +- 技术1 +- 技术2 +``` + +### 详细版(适用于复杂项目) +```markdown +## 背景 +[项目背景和动机] + +## 核心功能 +### 功能1 +[详细说明] + +### 功能2 +[详细说明] + +## 技术架构 +[技术选型和架构说明] + +## 使用场景 +- 场景1 +- 场景2 + +## 项目特点 +- 特点1 +- 特点2 +``` + +## 注意事项 + +1. **保持客观**:避免过度夸大或营销语言 +2. **用户视角**:从用户角度描述价值,而非技术实现细节 +3. **持续更新**:此标准会根据需要调整 +4. **灵活应用**:根据项目实际情况动态调整内容结构 diff --git a/github-project-analysis-prompt.md b/github-project-analysis-prompt.md deleted file mode 100644 index b0731f6..0000000 --- a/github-project-analysis-prompt.md +++ /dev/null @@ -1,511 +0,0 @@ -# GitHub 项目分析 Agent (增强版 - 支持图片) - -## 任务目标 - -分析单个 GitHub 项目并生成符合 webhook API 规范的 JSON 数据,用于项目入库。目标是生成包含丰富图文内容的高质量项目介绍页面。 - -## 输入数据 - -- **GitHub 项目 URL**: {{ $json.githubUrl }} - -## 可用工具 - -- **MCP Client (web_reader)**: 访问 GitHub 仓库页面及相关链接 - ---- - -## 执行步骤 - -### Step 1: 使用 MCP 获取项目基础信息 - -调用 MCP 工具访问 GitHub 仓库,提取: -- 仓库名称和描述 -- README.md 完整内容(**保留 Markdown 格式和图片链接**) -- GitHub Topics -- 主要编程语言 -- Stars、Forks 数量 -- 最新更新时间 -- 许可证类型 -- 主页 URL -- Releases 信息(如果有的话) - -**关键操作:提取图片列表** -- 从 README.md 中提取所有图片链接(`![alt](src)` 或 ``) -- 记录图片类型:架构图、截图、流程图、logo、GIF 动图等 -- 对于相对路径图片,转换为 GitHub 绝对 URL: - ``` - https://raw.githubusercontent.com/[owner]/[repo]/[branch]/[path] - ``` - -### Step 2: 深度理解项目价值和内容 - -从 README 内容中提炼(尽量保留图片): - -**项目用途**: -- 这个项目解决什么核心问题? -- 主要功能是什么? -- 有什么独特价值? -- 与同类项目相比的优势? - -**适用场景**: -- 谁会使用这个项目? -- 典型使用场景是什么? -- 属于哪个应用领域? - -**技术特点**: -- 使用了什么技术栈? -- 有什么技术亮点或创新点? -- 架构设计特点是什么? - -**如何使用**: -- 安装步骤 -- 配置说明 -- 快速开始指南 -- 常见操作 - -### Step 3: 生成中英双语内容 - -#### 3.1 name / nameEn - -- 通常使用英文名称 -- 如果有中文品牌名,使用原名 - -#### 3.2 description / descriptionEn ⚠️ 重要 - -简短的一句话总结,10-500 字符 - -**中文格式**: -``` -[项目名] 是一个[用途定位]的[类型],通过[核心特点]实现[价值主张] -``` - -示例: -``` -AutoGen 是一个由微软开发的多智能体应用框架,通过分层设计和可扩展架构,简化了构建能够自主运行或与人类协作的多智能体工作流程的开发流程 -``` - -**英文格式**: -``` -[Project] is a [type] for [purpose], featuring [key characteristics] -``` - -#### 3.3 content / contentEn ⚠️ 关键 - 严格结构 + 图片支持 - -完整的 Markdown 文档,最多 10000 字符。**必须严格按结构组织,并在适当位置嵌入图片**: - -```markdown -# 项目用途 - -[2-3句话详细描述项目的核心功能和解决的问题,说明项目的核心价值主张] - -[如果有项目 logo 或主展示图,在此插入] -![项目展示图](图片URL) - -# 适用场景 - -[列出3-5个典型使用场景] -- **[场景1名称]**:[具体说明,包含适用对象和具体用途] -- **[场景2名称]**:[具体说明,包含适用对象和具体用途] -- **[场景3名称]**:[具体说明,包含适用对象和具体用途] - -[如果有场景示意图,在此插入] -![架构图或场景图](图片URL) - -# 核心功能 - -[列出项目的主要功能特性,4-8项] -- **[功能1名称]**:[一句话说明这个功能的作用和价值] -- **[功能2名称]**:[一句话说明这个功能的作用和价值] -- **[功能3名称]**:[一句话说明这个功能的作用和价值] -- **[功能4名称]**:[一句话说明这个功能的作用和价值] - -[如果有功能截图,在此插入] -![功能演示截图](图片URL) - -# 技术架构 - -[详细说明技术架构和亮点,3-6点] -- **[技术1]**:[基于什么技术/框架,有什么特点] -- **[技术2]**:[支持什么具体特性,带来什么好处] -- **[技术3]**:[采用什么架构模式,解决什么问题] - -[**架构图优先在此位置插入**] -![系统架构图](图片URL) - -> 📐 **架构说明**:[对架构图的补充说明,描述主要组件、数据流向、技术栈等] - -# 如何使用 - -[详细的安装和配置步骤,6-10个步骤] -- **[步骤1标题]**:[具体操作,如安装命令、下载链接等] - ```bash - [命令示例] - ``` -- **[步骤2标题]**:[配置说明,如环境变量、配置文件等] -- **[步骤3标题]**:[创建或初始化项目] -- **[步骤4标题]**:[核心功能使用方法] -- **[步骤5标题]**:[常见操作说明] -- **[步骤6标题]**:[高级功能或最佳实践] - -[**如果有安装演示截图,在对应步骤后插入**] -![安装配置截图](图片URL) - -# 快速示例 - -[提供完整的、可运行的代码示例,10-20行代码] -```python/[javascript] -[从 README 中提取的实际代码示例,不要捏造] -``` - -[对代码示例的说明] - -[**如果有代码运行结果截图,在此插入**] -![运行效果截图](图片URL) - -# 实际效果展示 - -[如果有 GIF 动图或截图展示实际使用效果,在此集中展示] - -[**GIF 动图展示核心流程**] -![核心流程演示](GIF图片URL) - -[**界面截图展示**] -![界面截图1](图片URL) -![界面截图2](图片URL) - -> 💡 **效果说明**:[对截图/动图展示的功能进行说明] - -# 定价/成本 - -[说明项目的经济成本,明确透明] -- 开源免费:[如果完全免费,明确说明"完全免费和开源"] -- API 成本:[如果需要调用付费 API,说明相关成本] -- 企业版/付费版:[如果有商业版本,说明定价方案] -- 自部署成本:[如果需要自己部署,说明资源需求] - -# 常见问题 - -[3-5个 FAQ] -- Q: [问题1]? - A: [详细回答,2-3句话] -- Q: [问题2]? - A: [详细回答,2-3句话] -- Q: [问题3]? - A: [详细回答,2-3句话] -``` - -**英文版本保持相同结构**: - -```markdown -# Overview - -[2-3 sentences describing core functionality and value proposition] - -![Project Overview](Image URL) - -# Use Cases - -- **[Case 1]**: [Specific explanation] -- **[Case 2]**: [Specific explanation] - -![Use Case Diagram](Image URL) - -# Key Features - -- **[Feature 1]**: [One-sentence explanation] -- **[Feature 2]**: [One-sentence explanation] - -![Feature Screenshot](Image URL) - -# Technical Architecture - -- **[Tech 1]**: [Details] -- **[Tech 2]**: [Details] - -![Architecture Diagram](Image URL) - -> 📐 **Architecture Notes**: [Additional explanation of the architecture] - -# How to Use - -- **[Step 1]**: [Specific actions] - ```bash - [Commands] - ``` -- **[Step 2]**: [Configuration] - -![Setup Screenshot](Image URL) - -# Quick Example - -```python/[javascript] -[Code example from README] -``` - -![Result Screenshot](Image URL) - -# Live Demo - -![Demo GIF](GIF URL) - -![Interface Screenshot](Image URL) - -> 💡 **Demo Notes**: [Explanation of what's shown] - -# Pricing/Cost - -- Open Source: [Free or cost details] -- API Costs: [If applicable] - -# FAQ - -- Q: [Question 1]? - A: [Detailed answer] -``` - ---- - -### Step 4: 图片处理和质量控制 - -#### 4.1 图片 URL 规范化 - -**规则**: -1. 对于相对路径图片(如 `docs/architecture.png`),转换为: - ``` - https://raw.githubusercontent.com/[owner]/[repo]/[default-branch]/docs/architecture.png - ``` - -2. 对于已使用 `https://github.com/.../raw/...` 的链接,保持不变 - -3. 对于外部图片(如 imgur、cloudinary 等),保持原链接 - -4. **特殊处理**: - - 如果是 GitHub Issues/Comments 中的图片,通常在 `https://user-images.githubusercontent.com/` - - 如果是 docs 网站链接(如 `https://project.dev/images/...`),保持原链接 - -#### 4.2 图片选择优先级 - -**必须包含的图片类型**(按优先级): -1. **系统架构图**:展示技术栈、组件关系 -2. **功能演示 GIF**:展示核心工作流程 -3. **界面截图**:展示 UI/UX -4. **安装/配置截图**:帮助用户快速上手 -5. **数据流程图**:展示数据流向 -6. **部署架构图**:展示部署方案 - -**选择性包含**: -- Logo(可在顶部添加一次) -- 团队照片(非必需) -- 会议照片(非必需) - -**限制条件**: -- 最多包含 **15 张图片**(避免内容过于冗长) -- 优先选择高质量、信息量大的图片 -- 如果图片过大(>2MB),建议使用缩略图或描述替代 - -#### 4.3 图片描述规范 - -每个图片后应添加简短说明: - -```markdown -![架构图](图片URL) - -> 📐 **架构说明**:本项目采用微服务架构,包含 API Gateway、服务注册中心、3个核心微服务,使用 Redis 作为缓存,MySQL 作为持久化存储。 -``` - -特殊说明标签: -- `📐 架构说明` - 架构图 -- `💡 效果说明` - 功能演示 -- `⚙️ 配置说明` - 配置截图 -- `🎯 使用说明` - 操作演示 - ---- - -### Step 5: 提取标签 (6-20个) - -优先级顺序: -1. **核心技术**:LLM、Multi-Agent、Computer Vision、RAG -2. **编程语言**:Python、TypeScript、Rust -3. **框架/库**:React、PyTorch、LangChain -4. **应用领域**:NLP、Chatbot、Automation、DevOps -5. **公司/组织**:Microsoft、OpenAI、Meta - ---- - -### Step 6: 构造链接数组 ⚠️ 严格枚举值 - -**link.type 必须严格使用以下 4 种枚举值之一(全大写)**: - -| type 值 | 适用场景 | 示例 | -|---------|----------|------| -| `GITHUB` | GitHub 仓库地址 | `https://github.com/xxx/xxx` | -| `WEBSITE` | 官方文档/官网/博客/PyPI/npm | `https://example.com/docs` | -| `HUGGINGFACE` | Hugging Face 模型页 | `https://huggingface.co/xxx` | -| `PAPER` | 论文/Arxiv 链接 | `https://arxiv.org/abs/xxx` | - ---- - -### Step 7: 计算质量评分 - -```javascript -score = 0 -if (description.length >= 20 && description.length <= 200) score += 10 -if (content.length >= 1000) score += 15 -if (content.includes("# 如何使用") || content.includes("# How to Use")) score += 15 -if (content.includes("```")) score += 10 -if (content.includes("![") && content.match(/!\[.*\]\(.*\)/g).length >= 3) score += 15 // 🆕 包含3+图片 -if (content.includes("# 技术架构") || content.includes("# Technical Architecture")) score += 10 // 🆕 有架构说明 -if (stars >= 1000) score += 20 -else if (stars >= 100) score += 10 -if (最近30天有更新) score += 20 -else if (最近180天有更新) score += 10 -if (有文档链接) score += 10 -if (forks >= 10) score += 10 -``` - ---- - -## 🔴 关键输出要求(必须严格遵守) - -**⚠️ 直接返回纯 JSON 对象,严禁使用以下格式:** - -- ❌ **不要使用代码块标记**:禁止使用 ` ```json ` 或 ` ``` ` 包裹输出 -- ❌ **不要添加额外包装层**:禁止添加 `"output"`、`"data"` 等外层字段 -- ❌ **不要添加注释或解释**:禁止在 JSON 外添加任何文字说明 - -**✅ 正确的输出格式示例:** -```json -{"success": true, "project": {...}, "qualityScore": 95, "qualityPassed": true, "metadata": {...}} -``` - -**❌ 错误的输出格式示例:** -``` -```json -{ - "output": { - "success": true, - "project": {...} - } -} -``` -``` - -**检查方法**: -- 输出必须以 `{` 开头,以 `}` 结尾 -- 第一层必须直接包含 `success`、`project`、`qualityScore` 等字段 -- 不包含任何 Markdown 代码块标记 - ---- - -## ⚠️ 输出格式要求 - -**直接返回纯 JSON,不要使用代码块标记**: - -```json -{ - "success": true, - "project": { - "name": "项目名称", - "nameEn": "Project Name", - "description": "一句话描述,10-500字符", - "descriptionEn": "One sentence description, 10-500 chars", - "content": "# 项目用途\n\n完整Markdown内容,**包含图片链接**、架构图、使用示例等...", - "contentEn": "# Overview\n\nFull Markdown content with **image links**, architecture diagrams, usage examples...", - "status": "ACTIVE", - "source": "N8N_WORKFLOW", - "tags": [ - { "name": "核心技术", "nameEn": "Core Tech" } - ], - "links": [ - { "type": "GITHUB", "url": "...", "title": "GitHub 仓库" } - ] - }, - "qualityScore": 95, - "qualityPassed": true, - "metadata": { - "stars": 数量, - "forks": 数量, - "language": "主要语言", - "lastUpdate": "YYYY-MM-DD", - "analyzedAt": "ISO 8601格式", - "imageCount": 8 // 🆕 提取的图片数量 - } -} -``` - ---- - -## 📋 质量检查清单(生成前自查) - -### 基础要求 -- ✅ description 长度在 10-500 字符之间 -- ✅ content 包含完整的 8 个部分(项目用途、适用场景、核心功能、技术架构、如何使用、快速示例、实际效果展示、定价/成本、常见问题) -- ✅ content 包含至少一个代码示例(从 README 提取) -- ✅ content 包含至少 3 张图片(架构图、功能截图、演示 GIF 等) -- ✅ 图片 URL 已转换为可直接访问的绝对路径 -- ✅ 每张图片后有简短说明(使用 > 引用格式) -- ✅ tags 数量在 6-20 个之间 -- ✅ links 包含至少 GITHUB 类型链接 -- ✅ 所有枚举值使用全大写(ACTIVE、GITHUB、WEBSITE 等) -- ✅ 中英文内容结构一致 -- ✅ 没有使用 ```json 代码块包裹输出 - -### 图片质量检查 -- ✅ 架构图包含说明文字,解释主要组件和关系 -- ✅ 代码示例后有运行结果截图(如果有) -- ✅ "如何使用"部分的关键步骤有截图辅助说明 -- ✅ 所有图片链接可直接访问(非相对路径) -- ✅ 图片数量控制在 15 张以内,选择最具代表性的 - ---- - -## 🎯 最佳实践示例 - -### 好的架构图插入示例: - -```markdown -# 技术架构 - -LangChain.js 基于 TypeScript 重新实现,采用模块化设计: - -- **TypeScript + ESM**: 原生支持类型推断和 tree-shaking -- **模块化架构**: 核心 @langchain/core 与集成包分离,减小包体积 -- **Web-first**: 专为浏览器和 Edge Runtime 优化 - -![LangChain.js 架构图](https://raw.githubusercontent.com/langchain-ai/langchainjs/main/docs/static/img/architecture.png) - -> 📐 **架构说明**:左侧为 LangChain Core 核心模块(包含 Chains、Prompts、Models 等基础抽象),右侧为集成包(支持 OpenAI、Anthropic、向量数据库等)。底层统一使用 @langchain/core 的标准接口,上层应用可灵活组合不同集成。 -``` - -### 好的功能展示示例: - -```markdown -# 实际效果展示 - -通过对话式接口创建 Multi-Agent 系统: - -![Multi-Agent 创建流程](https://github.com/microsoft/autogen/raw/main/website/static/dev/chat-creation-demo.gif) - -> 💡 **效果说明**:用户输入"创建一个多智能体系统用于代码审查",Agent 会自动: -> 1. 创建 Assistant Agent(负责代码分析) -> 2. 创建 User Proxy Agent(负责执行代码) -> 3. 配置两人之间的对话模式 -> 4. 自动生成初始提示词 - -以下是一个真实的对话示例: - -![对话示例](https://raw.githubusercontent.com/microsoft/autogen/main/docs/images/chat-example.png) -``` - ---- - -## ⚠️ 最终检查清单(输出前必须确认) - -在返回结果前,请确认: -- [ ] 输出以 `{` 开头,以 `}` 结尾 -- [ ] 没有任何 Markdown 代码块标记(```json 或 ```) -- [ ] 第一层直接包含 `success` 字段(没有 `output` 包装) -- [ ] 没有在 JSON 外添加任何文字说明 -- [ ] 图片是项目介绍的重要组成部分,已妥善处理 - -**🔴 最后提醒:直接输出纯 JSON 对象,不要用代码块包裹,不要添加包装层!** diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8f2aedf..2ed8aa0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,6 +27,13 @@ enum LinkType { PAPER } +enum TaskStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED +} + // ================================ // Models // ================================ @@ -48,6 +55,7 @@ model Project { // Relations tags ProjectTag[] links ExternalLink[] + discoveryTasks ProjectDiscoveryTask[] // Indexes @@index([status, createdAt], map: "idx_project_status_createdAt") @@ -101,3 +109,38 @@ model ProjectTag { @@index([tagId]) @@map("project_tags") } + +// Project Discovery Task model +model ProjectDiscoveryTask { + id String @id @default(cuid()) + status TaskStatus @default(PENDING) + + // 原始数据(仅URL) + sourceUrl String + sourceType String @default("manual") + + // 探索结果 + explorationData Json? + explorationSummary String? @db.Text + + // 错误处理 + errorMessage String? @db.Text + retryCount Int @default(0) + lastRetryAt DateTime? + + // 关联 + projectId String? + project Project? @relation(fields: [projectId], references: [id]) + + // 时间戳 + createdAt DateTime @default(now()) + startedAt DateTime? + completedAt DateTime? + updatedAt DateTime @updatedAt + + // 索引 + @@index([status, createdAt], map: "idx_task_status_created") + @@index([sourceUrl], map: "idx_task_source_url") + @@index([projectId], map: "idx_task_project_id") + @@map("project_discovery_tasks") +} diff --git a/src/app/api/discovery/lib/discovery-service.ts b/src/app/api/discovery/lib/discovery-service.ts new file mode 100644 index 0000000..da7a04d --- /dev/null +++ b/src/app/api/discovery/lib/discovery-service.ts @@ -0,0 +1,133 @@ +import { prisma } from '@/lib/prisma' +import type { ProjectInput } from '@/lib/validations' +import { generateSlug } from '@/lib/slug' + +/** + * 多级去重策略:查找已存在的项目 + * + * 优先级: + * 1. GitHub URL 完全匹配(最准确) + * 2. Website URL 完全匹配 + * 3. slug 匹配(兜底) + * + * @param projectData - 项目数据 + * @returns 已存在的项目,如果不存在则返回 null + */ +export async function findExistingProject(projectData: ProjectInput) { + // 优先级1: 通过 GitHub URL 匹配 + const githubLink = projectData.links.find((link) => link.type === 'GITHUB') + if (githubLink) { + const existingByGithub = await prisma.externalLink.findFirst({ + where: { + type: 'GITHUB', + url: githubLink.url, + }, + include: { + project: { + include: { + links: true, + }, + }, + }, + }) + + if (existingByGithub) { + console.warn( + `[Discovery] Found existing project by GitHub URL: ${githubLink.url}` + ) + return existingByGithub.project + } + } + + // 优先级2: 通过 Website URL 匹配 + const websiteLink = projectData.links.find((link) => link.type === 'WEBSITE') + if (websiteLink) { + const existingByWebsite = await prisma.externalLink.findFirst({ + where: { + type: 'WEBSITE', + url: websiteLink.url, + }, + include: { + project: { + include: { + links: true, + }, + }, + }, + }) + + if (existingByWebsite) { + console.warn( + `[Discovery] Found existing project by Website URL: ${websiteLink.url}` + ) + return existingByWebsite.project + } + } + + // 优先级3: 通过 slug 匹配(兜底) + const slug = generateSlug(projectData.name, projectData.nameEn) + + const existingBySlug = await prisma.project.findUnique({ + where: { slug }, + include: { + links: true, + }, + }) + + if (existingBySlug) { + console.warn(`[Discovery] Found existing project by slug: ${slug}`) + return existingBySlug + } + + console.warn(`[Discovery] No existing project found, will create new one`) + return null +} + +/** + * 批量创建或获取标签 + * + * @param tags - 标签数组 + * @returns 标签连接对象数组 + */ +export async function upsertTags(tags: ProjectInput['tags']) { + // 优化:批量查询所有已存在的标签,避免 N+1 问题 + const allTagNames = tags.map((t) => t.name) + const existingTags = await prisma.tag.findMany({ + where: { name: { in: allTagNames } }, + }) + const existingTagNames = new Set(existingTags.map((t) => t.name)) + + // Upsert tags - 优化后只查询不存在的标签 + return await Promise.all( + tags.map(async (tag) => { + const tagSlug = generateSlug(tag.name, tag.nameEn) + + // 首先从批量查询结果中查找 + if (existingTagNames.has(tag.name)) { + return existingTags.find((t) => t.name === tag.name)! + } + + // 只有标签不存在时才尝试 upsert + try { + return await prisma.tag.upsert({ + where: { slug: tagSlug }, + update: {}, + create: { + name: tag.name, + nameEn: tag.nameEn || null, + slug: tagSlug, + }, + }) + } catch (error) { + // 如果 slug 冲突,查找并使用已存在的标签 + const existingBySlug = await prisma.tag.findUnique({ + where: { slug: tagSlug }, + }) + if (existingBySlug) { + return existingBySlug + } + throw error + } + }) + ) +} diff --git a/src/app/api/discovery/tasks/[id]/complete/route.ts b/src/app/api/discovery/tasks/[id]/complete/route.ts new file mode 100644 index 0000000..7b0826e --- /dev/null +++ b/src/app/api/discovery/tasks/[id]/complete/route.ts @@ -0,0 +1,202 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { prisma } from '@/lib/prisma' +import { generateSlug } from '@/lib/slug' +import { + ProjectInputSchema, + type ProjectInput, +} from '@/lib/validations' +import { findExistingProject, upsertTags } from '../../../lib/discovery-service' + +/** + * POST /api/discovery/tasks/:id/complete + * 完成探索并创建项目 + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const startTime = Date.now() + const { id: taskId } = await params + + try { + const body = await request.json() + const { apiKey, explorationData } = body + + // 验证API密钥 + const validApiKey = process.env.WEBHOOK_API_KEY + if ( + !validApiKey || + !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) + ) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + // 验证探索数据格式(符合ProjectInputSchema) + const projectValidation = ProjectInputSchema.safeParse(explorationData) + + if (!projectValidation.success) { + console.error( + `[Discovery] Invalid exploration data for task ${taskId}:`, + projectValidation.error.errors + ) + return NextResponse.json( + { + success: false, + error: 'Invalid exploration data format', + details: projectValidation.error.errors, + }, + { status: 400 } + ) + } + + const projectData = projectValidation.data as ProjectInput + + // 查找已存在的项目(复用webhook的多级去重逻辑) + const existingProject = await findExistingProject(projectData) + + // Upsert tags(复用服务层函数) + const tagConnections = await upsertTags(projectData.tags) + + // 生成slug + const slug = generateSlug(projectData.name, projectData.nameEn) + + // 使用事务确保项目创建/更新和任务状态更新的原子性 + const result = await prisma.$transaction(async (tx) => { + let projectId: string + + if (existingProject) { + // 更新现有项目 + console.log( + `[Discovery] Updating existing project for task ${taskId}: ${projectData.name}` + ) + + await tx.projectTag.deleteMany({ + where: { projectId: existingProject.id }, + }) + + await tx.project.update({ + where: { id: existingProject.id }, + data: { + name: projectData.name, + nameEn: projectData.nameEn || null, + description: projectData.description, + descriptionEn: projectData.descriptionEn || null, + content: projectData.content || null, + contentEn: projectData.contentEn || null, + status: projectData.status, + source: projectData.source || 'discovery', + tags: { + create: tagConnections.map((t) => ({ + tag: { connect: { id: t.id } }, + })), + }, + }, + }) + + await tx.externalLink.deleteMany({ + where: { projectId: existingProject.id }, + }) + + await tx.externalLink.createMany({ + data: projectData.links.map((link) => ({ + type: link.type, + url: link.url, + title: link.title || null, + projectId: existingProject.id, + })), + }) + + projectId = existingProject.id + } else { + // 创建新项目 + console.log( + `[Discovery] Creating new project for task ${taskId}: ${projectData.name}` + ) + + const newProject = await tx.project.create({ + data: { + name: projectData.name, + nameEn: projectData.nameEn || null, + slug, + description: projectData.description, + descriptionEn: projectData.descriptionEn || null, + content: projectData.content || null, + contentEn: projectData.contentEn || null, + status: projectData.status, + source: projectData.source || 'discovery', + tags: { + create: tagConnections.map((t) => ({ + tag: { connect: { id: t.id } }, + })), + }, + links: { + create: projectData.links.map((link) => ({ + type: link.type, + url: link.url, + title: link.title || null, + })), + }, + }, + }) + + projectId = newProject.id + } + + // 在同一事务中更新任务状态为COMPLETED + const updatedTask = await tx.projectDiscoveryTask.update({ + where: { id: taskId }, + data: { + status: 'COMPLETED', + completedAt: new Date(), + explorationData: explorationData, + projectId, + }, + }) + + return { projectId, updatedTask } + }) + + const duration = Date.now() - startTime + console.log( + `[Discovery] Completed task ${taskId} in ${duration}ms, project: ${result.projectId}` + ) + + return NextResponse.json({ + success: true, + taskId, + projectId: result.projectId, + action: existingProject ? 'updated' : 'created', + duration, + }) + } catch (error) { + console.error('[Discovery] Error completing task:', error) + + // 失败时更新任务状态为FAILED + try { + await prisma.projectDiscoveryTask.update({ + where: { id: taskId }, + data: { + status: 'FAILED', + completedAt: new Date(), + errorMessage: error instanceof Error ? error.message : 'Unknown error', + retryCount: { increment: 1 }, + }, + }) + } catch (updateError) { + console.error('[Discovery] Failed to update task status:', updateError) + } + + return NextResponse.json( + { + success: false, + error: 'Internal server error', + details: [error instanceof Error ? error.message : 'Unknown error'], + }, + { status: 500 } + ) + } +} diff --git a/src/app/api/discovery/tasks/[id]/route.ts b/src/app/api/discovery/tasks/[id]/route.ts new file mode 100644 index 0000000..28a6e64 --- /dev/null +++ b/src/app/api/discovery/tasks/[id]/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { prisma } from '@/lib/prisma' +import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations' +import type { Prisma } from '@prisma/client' + +/** + * 有效的任务状态转换规则 + * PENDING -> IN_PROGRESS + * IN_PROGRESS -> COMPLETED | FAILED + * FAILED -> PENDING (允许重试) + * COMPLETED -> (终态,不允许转换) + */ +const VALID_STATUS_TRANSITIONS: Record = { + PENDING: ['IN_PROGRESS'], + IN_PROGRESS: ['COMPLETED', 'FAILED'], + COMPLETED: [], + FAILED: ['PENDING'], +} + +/** + * 验证状态转换是否合法 + */ +function isValidStatusTransition(from: TaskStatus, to: TaskStatus): boolean { + return VALID_STATUS_TRANSITIONS[from].includes(to) +} + +/** + * GET /api/discovery/tasks/:id + * 获取单个任务详情 + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const task = await prisma.projectDiscoveryTask.findUnique({ + where: { id }, + }) + + if (!task) { + return NextResponse.json( + { success: false, error: 'Task not found' }, + { status: 404 } + ) + } + + return NextResponse.json({ + success: true, + task, + }) + } catch (error) { + console.error('[Discovery] Error fetching task:', error) + return NextResponse.json( + { success: false, error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * PATCH /api/discovery/tasks/:id + * 更新任务状态 + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const validation = UpdateDiscoveryTaskSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: validation.error.errors.map((e) => e.message), + }, + { status: 400 } + ) + } + + const { apiKey, status, explorationData, explorationSummary, errorMessage } = + validation.data + + // 验证API密钥 + const validApiKey = process.env.WEBHOOK_API_KEY + if ( + !validApiKey || + !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) + ) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + // 获取当前任务状态以验证状态转换 + const existingTask = await prisma.projectDiscoveryTask.findUnique({ + where: { id }, + select: { status: true }, + }) + + if (!existingTask) { + return NextResponse.json( + { success: false, error: 'Task not found' }, + { status: 404 } + ) + } + + // 验证状态转换是否合法 + if (!isValidStatusTransition(existingTask.status, status)) { + return NextResponse.json( + { + success: false, + error: 'Invalid status transition', + details: [ + `Cannot transition from ${existingTask.status} to ${status}. Valid transitions: ${VALID_STATUS_TRANSITIONS[existingTask.status].join(', ')}`, + ], + }, + { status: 400 } + ) + } + + // 定义更新数据类型 + interface TaskUpdateData { + status: TaskStatus + startedAt?: Date + completedAt?: Date + explorationData?: Prisma.InputJsonValue + explorationSummary?: string | null + errorMessage?: string | null + } + + const updateData: TaskUpdateData = { status } + + if (status === 'IN_PROGRESS') { + updateData.startedAt = new Date() + } else if (status === 'COMPLETED' || status === 'FAILED') { + updateData.completedAt = new Date() + } + + if (explorationData !== undefined) updateData.explorationData = explorationData as Prisma.InputJsonObject + if (explorationSummary !== undefined) updateData.explorationSummary = explorationSummary + if (errorMessage !== undefined) updateData.errorMessage = errorMessage + + const task = await prisma.projectDiscoveryTask.update({ + where: { id }, + data: updateData, + }) + + console.log(`[Discovery] Updated task ${id} to status: ${status}`) + + return NextResponse.json({ + success: true, + task, + }) + } catch (error) { + console.error('[Discovery] Error updating task:', error) + return NextResponse.json( + { success: false, error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/discovery/tasks/route.ts b/src/app/api/discovery/tasks/route.ts new file mode 100644 index 0000000..ae8c5d6 --- /dev/null +++ b/src/app/api/discovery/tasks/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from 'next/server' +import crypto from 'crypto' +import { prisma } from '@/lib/prisma' +import { + CreateDiscoveryTaskSchema, + GetDiscoveryTasksQuerySchema, +} from '@/lib/validations' + +/** + * POST /api/discovery/tasks + * 创建新的探索任务 + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const validation = CreateDiscoveryTaskSchema.safeParse(body) + + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: validation.error.errors.map((e) => e.message), + }, + { status: 400 } + ) + } + + const { apiKey, tasks } = validation.data + + // 验证API密钥 + const validApiKey = process.env.WEBHOOK_API_KEY + if ( + !validApiKey || + !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) + ) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + // 去重:检查URL是否已存在任务 + const existingUrls = new Set( + ( + await prisma.projectDiscoveryTask.findMany({ + where: { sourceUrl: { in: tasks.map((t) => t.sourceUrl) } }, + select: { sourceUrl: true }, + }) + ).map((t) => t.sourceUrl) + ) + + // 创建新任务(跳过已存在的) + const newTasks = tasks.filter((t) => !existingUrls.has(t.sourceUrl)) + + if (newTasks.length === 0) { + return NextResponse.json({ + success: true, + created: 0, + skipped: tasks.length, + total: tasks.length, + message: 'All tasks already exist', + }) + } + + const created = await prisma.projectDiscoveryTask.createMany({ + data: newTasks.map((t) => ({ + sourceUrl: t.sourceUrl, + sourceType: t.sourceType, + status: 'PENDING', + })), + }) + + console.log( + `[Discovery] Created ${created.count} tasks, skipped ${tasks.length - created.count} existing tasks` + ) + + return NextResponse.json({ + success: true, + created: created.count, + skipped: tasks.length - created.count, + total: tasks.length, + }) + } catch (error) { + console.error('[Discovery] Error creating tasks:', error) + return NextResponse.json( + { success: false, error: 'Internal server error' }, + { status: 500 } + ) + } +} + +/** + * GET /api/discovery/tasks + * 获取探索任务列表 + */ +export async function GET(request: NextRequest) { + try { + // 验证 API Key(只读权限) + const apiKey = request.headers.get('x-api-key') + const validApiKey = process.env.WEBHOOK_API_KEY + if ( + !validApiKey || + !apiKey || + !crypto.timingSafeEqual(Buffer.from(apiKey), Buffer.from(validApiKey)) + ) { + return NextResponse.json( + { success: false, error: 'Unauthorized' }, + { status: 401 } + ) + } + + const { searchParams } = new URL(request.url) + const validation = GetDiscoveryTasksQuerySchema.safeParse({ + status: searchParams.get('status') || undefined, + limit: searchParams.get('limit') || '10', + offset: searchParams.get('offset') || '0', + }) + + if (!validation.success) { + return NextResponse.json( + { + success: false, + error: 'Validation error', + details: validation.error.errors.map((e) => e.message), + }, + { status: 400 } + ) + } + + const { status, limit, offset } = validation.data + + const tasks = await prisma.projectDiscoveryTask.findMany({ + where: status ? { status } : undefined, + orderBy: { createdAt: 'asc' }, + take: limit, + skip: offset, + }) + + const total = await prisma.projectDiscoveryTask.count({ + where: status ? { status } : undefined, + }) + + return NextResponse.json({ + success: true, + tasks, + total, + hasMore: offset + tasks.length < total, + }) + } catch (error) { + console.error('[Discovery] Error fetching tasks:', error) + return NextResponse.json( + { success: false, error: 'Internal server error' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/webhook/projects/route.ts b/src/app/api/webhook/projects/route.ts index 9c0ea63..a8bd58a 100644 --- a/src/app/api/webhook/projects/route.ts +++ b/src/app/api/webhook/projects/route.ts @@ -8,91 +8,9 @@ import { type WebhookPayload, type ProjectInput, } from '@/lib/validations' +import { findExistingProject } from '../../discovery/lib/discovery-service' import { generateSlug } from '@/lib/slug' -/** - * 多级去重策略:查找已存在的项目 - * - * 优先级: - * 1. GitHub URL 完全匹配(最准确) - * 2. Website URL 完全匹配 - * 3. slug 匹配(兜底) - * - * @param projectData - 项目数据 - * @returns 已存在的项目,如果不存在则返回 null - */ -async function findExistingProject(projectData: ProjectInput) { - // 优先级1: 通过 GitHub URL 匹配 - const githubLink = projectData.links.find((link) => link.type === 'GITHUB') - if (githubLink) { - const existingByGithub = await prisma.externalLink.findFirst({ - where: { - type: 'GITHUB', - url: githubLink.url, - }, - include: { - project: { - include: { - links: true, - }, - }, - }, - }) - - if (existingByGithub) { - console.warn( - `[Webhook] Found existing project by GitHub URL: ${githubLink.url}` - ) - return existingByGithub.project - } - } - - // 优先级2: 通过 Website URL 匹配 - const websiteLink = projectData.links.find( - (link) => link.type === 'WEBSITE' - ) - if (websiteLink) { - const existingByWebsite = await prisma.externalLink.findFirst({ - where: { - type: 'WEBSITE', - url: websiteLink.url, - }, - include: { - project: { - include: { - links: true, - }, - }, - }, - }) - - if (existingByWebsite) { - console.warn( - `[Webhook] Found existing project by Website URL: ${websiteLink.url}` - ) - return existingByWebsite.project - } - } - - // 优先级3: 通过 slug 匹配(兜底) - const slug = generateSlug(projectData.name, projectData.nameEn) - - const existingBySlug = await prisma.project.findUnique({ - where: { slug }, - include: { - links: true, - }, - }) - - if (existingBySlug) { - console.warn(`[Webhook] Found existing project by slug: ${slug}`) - return existingBySlug - } - - console.warn(`[Webhook] No existing project found, will create new one`) - return null -} - export async function POST(request: NextRequest) { const startTime = Date.now() @@ -222,7 +140,7 @@ export async function POST(request: NextRequest) { ? 'slug' : validProject.links.some( (l) => - existingProject.links.some((el) => el.url === l.url) + existingProject.links.some((el: { url: string }) => el.url === l.url) ) ? 'url' : 'unknown' diff --git a/src/lib/validations.ts b/src/lib/validations.ts index bac8ca7..564e971 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -64,6 +64,33 @@ export const WebhookPayloadSchema = WebhookAuthSchema.extend({ projects: z.array(ProjectInputSchema).min(1).max(100) }) +// ================================ +// Discovery Task Schemas +// ================================ + +export const TaskStatusEnum = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED']) + +export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({ + tasks: z.array(z.object({ + sourceUrl: z.string().url().max(2000), + sourceType: z.string().max(50).default('manual'), + })).min(1).max(50) +}) + +export const UpdateDiscoveryTaskSchema = z.object({ + apiKey: z.string().min(32), + status: TaskStatusEnum, + explorationData: z.record(z.unknown()).optional(), + explorationSummary: z.string().max(1000).optional(), + errorMessage: z.string().max(2000).optional(), +}) + +export const GetDiscoveryTasksQuerySchema = z.object({ + status: TaskStatusEnum.optional(), + limit: z.coerce.number().int().positive().max(100).default(10), + offset: z.coerce.number().int().nonnegative().default(0), +}) + // ================================ // Query Schemas // ================================ @@ -85,3 +112,7 @@ export type Tag = z.infer export type ProjectInput = z.infer export type WebhookPayload = z.infer export type ProjectQuery = z.infer +export type TaskStatus = z.infer +export type CreateDiscoveryTask = z.infer +export type UpdateDiscoveryTask = z.infer +export type GetDiscoveryTasksQuery = z.infer