feat: 新增项目发现任务系统并修复代码审查发现的高优先级问题
新增功能: - 新增 ProjectDiscoveryTask 数据模型,支持异步项目探索任务管理 - 新增 /api/discovery/tasks CRUD API,支持任务创建、查询和状态更新 - 新增 /api/discovery/tasks/:id/complete API,完成探索并自动创建项目 - 新增 discovery-service 服务层,封装多级去重和标签处理逻辑 - 新增 discover-projects 命令,支持批量探索项目 - 新增 PROJECT_CONTENT_STANDARD.md 内容标准模板 代码质量修复: - 修复状态转换验证未使用问题,防止非法状态转换 - 修复 explorationData 类型安全问题,使用 z.record(z.unknown()) 替代 z.any() - 添加事务保护到 complete API,确保项目创建和任务状态更新的原子性 代码重构: - 将 webhook/projects 的去重逻辑迁移到 discovery-service - 统一探索任务相关的验证模式到 validations.ts Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<TaskStatus, TaskStatus[]> = {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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<typeof TagSchema>
|
||||
export type ProjectInput = z.infer<typeof ProjectInputSchema>
|
||||
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>
|
||||
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>
|
||||
export type TaskStatus = z.infer<typeof TaskStatusEnum>
|
||||
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>
|
||||
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>
|
||||
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>
|
||||
|
||||
Reference in New Issue
Block a user