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:
2026-01-17 15:44:33 +08:00
co-authored by Claude
parent 2025db4a47
commit e3e72006b0
11 changed files with 942 additions and 601 deletions
@@ -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 }
)
}
}
+168
View File
@@ -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 }
)
}
}
+157
View File
@@ -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 }
)
}
}
+2 -84
View File
@@ -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'
+31
View File
@@ -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>