feat: 添加项目发现去重检查 API
新增去重检查功能,避免创建重复的探索任务: API 端点:POST /api/discovery/check-duplicates 功能特性: - 检查 URL 是否已存在 PENDING/IN_PROGRESS 任务 - 检查 URL 是否已存在 COMPLETED/FAILED 任务 - 检查 URL 对应的项目是否已通过 ExternalLink 收录 - 返回每条 URL 的 shouldCreate 判断和建议原因 新增文件: - src/app/api/discovery/check-duplicates/route.ts: 去重检查逻辑 - src/lib/validations.ts: CheckTaskDuplicatesSchema 文档更新: - docs/discovery-workflow.md: 添加步骤 2.5 去重检查文档 - API 调用示例 - 返回结果说明 - 去重逻辑优先级 - n8n 集成建议
This commit is contained in:
+115
-4
@@ -15,14 +15,22 @@
|
||||
├─ 筛选符合条件的项目链接
|
||||
│
|
||||
▼
|
||||
2. 调用创建任务 API
|
||||
2. 调用去重检查 API (推荐)
|
||||
POST /api/discovery/check-duplicates
|
||||
│
|
||||
├─ 检查 URL 是否已存在任务
|
||||
├─ 检查 URL 对应项目是否已收录
|
||||
├─ 过滤出需要创建的 URL
|
||||
│
|
||||
▼
|
||||
3. 调用创建任务 API
|
||||
POST /api/discovery/tasks
|
||||
│
|
||||
├─ 存入 ProjectDiscoveryTask 表
|
||||
├─ 状态: PENDING
|
||||
│
|
||||
▼
|
||||
3. 手动触发本地命令 (定期执行)
|
||||
4. 手动触发本地命令 (定期执行)
|
||||
/discover-projects
|
||||
│
|
||||
├─ 通过 curl 获取待处理任务
|
||||
@@ -38,7 +46,7 @@
|
||||
│ └─ 自动重试失败的提交
|
||||
│
|
||||
▼
|
||||
4. 完成任务并入库
|
||||
5. 完成任务并入库
|
||||
POST /api/discovery/tasks/{id}/complete
|
||||
│
|
||||
├─ 验证数据格式 (Zod Schema)
|
||||
@@ -50,7 +58,7 @@
|
||||
├─ 更新任务状态: COMPLETED/FAILED
|
||||
│
|
||||
▼
|
||||
5. 数据已入库,可在前台展示
|
||||
6. 数据已入库,可在前台展示
|
||||
```
|
||||
|
||||
---
|
||||
@@ -108,6 +116,109 @@ curl -X POST https://your-domain.com/api/discovery/tasks \
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2.5: 调用去重检查 API(推荐)
|
||||
|
||||
**API 端点**: `POST /api/discovery/check-duplicates`
|
||||
|
||||
**调用示例**:
|
||||
```bash
|
||||
curl -X POST https://your-domain.com/api/discovery/check-duplicates \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"apiKey": "YOUR_API_KEY",
|
||||
"urls": [
|
||||
"https://github.com/langchain-ai/langchain",
|
||||
"https://github.com/openai/openai-quickstart-python"
|
||||
]
|
||||
}'
|
||||
```
|
||||
|
||||
**返回结果**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": [
|
||||
{
|
||||
"url": "https://github.com/langchain-ai/langchain",
|
||||
"shouldCreate": false,
|
||||
"reason": "Task already exists with status PENDING",
|
||||
"existingTask": {
|
||||
"id": "cmxxxxx",
|
||||
"status": "PENDING",
|
||||
"sourceUrl": "https://github.com/langchain-ai/langchain",
|
||||
"createdAt": "2025-01-18T10:00:00Z",
|
||||
"projectId": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/openai/openai-quickstart-python",
|
||||
"shouldCreate": true,
|
||||
"reason": "No existing task or project found"
|
||||
}
|
||||
],
|
||||
"stats": {
|
||||
"total": 2,
|
||||
"shouldCreate": 1,
|
||||
"duplicate": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**去重逻辑**(按优先级):
|
||||
1. **优先级 1**: 检查是否有 PENDING/IN_PROGRESS 的相同 URL 任务
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:任务已在处理中,避免重复探索
|
||||
|
||||
2. **优先级 2**: 检查是否有 COMPLETED/FAILED 的相同 URL 任务
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:任务已探索过,无需重复
|
||||
|
||||
3. **优先级 3**: 检查 URL 对应的项目是否已存在(通过 ExternalLink)
|
||||
- 如果存在 → `shouldCreate: false`
|
||||
- 原因:项目已通过其他来源收录
|
||||
|
||||
4. **默认**: 允许创建新任务
|
||||
- `shouldCreate: true`
|
||||
|
||||
**n8n 集成建议**:
|
||||
```javascript
|
||||
// n8n Workflow 示例
|
||||
const checkResponse = await fetch('https://your-domain.com/api/discovery/check-duplicates', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
urls: collectedUrls // 从上一步收集的 URL 列表
|
||||
})
|
||||
})
|
||||
|
||||
const { results, stats } = await checkResponse.json()
|
||||
|
||||
// 过滤出应该创建任务的 URL
|
||||
const urlsToCreate = results
|
||||
.filter(r => r.shouldCreate)
|
||||
.map(r => r.url)
|
||||
|
||||
// 只为不重复的 URL 创建任务
|
||||
if (urlsToCreate.length > 0) {
|
||||
await fetch('https://your-domain.com/api/discovery/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
apiKey: 'YOUR_API_KEY',
|
||||
tasks: urlsToCreate.map(url => ({
|
||||
sourceUrl: url,
|
||||
sourceType: 'github_trending'
|
||||
}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`创建 ${urlsToCreate.length} 个新任务,跳过 ${stats.duplicate} 个重复任务`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3: 手动触发本地命令
|
||||
|
||||
**执行环境**: 本地开发环境 (Local Development)
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { CheckTaskDuplicatesSchema } from '@/lib/validations'
|
||||
|
||||
/**
|
||||
* 检查 URL 是否应该创建新任务
|
||||
*
|
||||
* 去重优先级:
|
||||
* 1. PENDING/IN_PROGRESS 任务 → 不创建(任务处理中)
|
||||
* 2. COMPLETED/FAILED 任务 → 不创建(已探索过)
|
||||
* 3. 已存在的项目(通过 ExternalLink)→ 不创建(已收录)
|
||||
* 4. 无任何记录 → 允许创建
|
||||
*
|
||||
* @param url - 要检查的 URL
|
||||
* @returns 检查结果
|
||||
*/
|
||||
async function checkUrlDuplicate(url: string): Promise<{
|
||||
url: string
|
||||
shouldCreate: boolean
|
||||
reason: string
|
||||
existingTask?: {
|
||||
id: string
|
||||
status: string
|
||||
sourceUrl: string
|
||||
createdAt: Date
|
||||
projectId?: string | null
|
||||
}
|
||||
existingProject?: {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
}> {
|
||||
// 优先级 1: 检查是否有 PENDING/IN_PROGRESS 的相同 URL 任务
|
||||
const activeTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ['PENDING', 'IN_PROGRESS'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (activeTask) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: `Task already exists with status ${activeTask.status}`,
|
||||
existingTask: activeTask,
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级 2: 检查是否有 COMPLETED/FAILED 的相同 URL 任务
|
||||
const finishedTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ['COMPLETED', 'FAILED'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
})
|
||||
|
||||
if (finishedTask) {
|
||||
// 如果任务已完成,返回项目信息
|
||||
let projectInfo
|
||||
if (finishedTask.status === 'COMPLETED' && finishedTask.projectId) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: finishedTask.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
})
|
||||
if (project) {
|
||||
projectInfo = project
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason:
|
||||
finishedTask.status === 'COMPLETED'
|
||||
? 'Task already completed'
|
||||
: 'Task already failed',
|
||||
existingTask: finishedTask,
|
||||
existingProject: projectInfo,
|
||||
}
|
||||
}
|
||||
|
||||
// 优先级 3: 检查 URL 对应的项目是否已存在(通过 ExternalLink)
|
||||
const existingLink = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
url: url,
|
||||
},
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (existingLink) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: 'Project already exists with this URL',
|
||||
existingProject: existingLink.project,
|
||||
}
|
||||
}
|
||||
|
||||
// 默认: 允许创建新任务
|
||||
return {
|
||||
url,
|
||||
shouldCreate: true,
|
||||
reason: 'No existing task or project found',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/discovery/check-duplicates
|
||||
*
|
||||
* 检查 URL 是否应该创建新的探索任务
|
||||
*
|
||||
* 请求体:
|
||||
* {
|
||||
* "apiKey": "xxx",
|
||||
* "urls": ["https://github.com/user/repo", ...],
|
||||
* "sourceType": "manual" // 可选
|
||||
* }
|
||||
*
|
||||
* 返回:
|
||||
* {
|
||||
* "success": true,
|
||||
* "results": [
|
||||
* {
|
||||
* "url": "...",
|
||||
* "shouldCreate": false,
|
||||
* "reason": "Task already exists with status PENDING",
|
||||
* "existingTask": {...},
|
||||
* "existingProject": {...}
|
||||
* }
|
||||
* ],
|
||||
* "stats": {
|
||||
* "total": 10,
|
||||
* "shouldCreate": 5,
|
||||
* "duplicate": 5
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// 验证请求体
|
||||
const validationResult = CheckTaskDuplicatesSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: validationResult.error.errors.map((e) => e.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const { apiKey, urls, sourceType } = validationResult.data
|
||||
|
||||
// 验证 API Key
|
||||
const validApiKey = process.env.WEBHOOK_API_KEY
|
||||
if (
|
||||
!validApiKey ||
|
||||
!crypto.timingSafeEqual(
|
||||
Buffer.from(apiKey),
|
||||
Buffer.from(validApiKey)
|
||||
)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 并行检查所有 URL
|
||||
const results = await Promise.all(urls.map((url) => checkUrlDuplicate(url)))
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
total: results.length,
|
||||
shouldCreate: results.filter((r) => r.shouldCreate).length,
|
||||
duplicate: results.filter((r) => !r.shouldCreate).length,
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
console.warn(
|
||||
`[CheckTaskDuplicates] Checked ${stats.total} URLs in ${duration}ms: ${stats.shouldCreate} should create, ${stats.duplicate} duplicate`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
stats,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[CheckTaskDuplicates] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,15 @@ export const GetDiscoveryTasksQuerySchema = z.object({
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查任务去重 Schema
|
||||
* 用于在创建任务前检查 URL 是否已存在
|
||||
*/
|
||||
export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
|
||||
urls: z.array(z.string().url().max(2000)).min(1).max(100),
|
||||
sourceType: z.string().max(50).optional(),
|
||||
})
|
||||
|
||||
// ================================
|
||||
// Query Schemas
|
||||
// ================================
|
||||
|
||||
Reference in New Issue
Block a user