chore: remove admin and agent pipeline remnants
This commit is contained in:
@@ -7,11 +7,6 @@ WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
||||
# n8n AI Search Webhook
|
||||
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
||||
|
||||
# n8n Keyword Cloud Workflow Environment Variables
|
||||
# These variables should be set in your n8n environment
|
||||
# N8N_API_URL: Your API endpoint URL (e.g., http://localhost:3000 for local, or your production URL)
|
||||
# N8N_API_KEY: Use the same value as WEBHOOK_API_KEY above
|
||||
|
||||
# Internationalization
|
||||
NEXT_INTL_DEFAULT_LOCALE="zh"
|
||||
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DROP TABLE IF EXISTS project_discovery_tasks;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type
|
||||
WHERE typname = 'TaskStatus'
|
||||
) THEN
|
||||
DROP TYPE "TaskStatus";
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
+27
-58
@@ -24,34 +24,11 @@ model ExternalLink {
|
||||
@@map("external_links")
|
||||
}
|
||||
|
||||
model ProjectDiscoveryTask {
|
||||
id String @id @default(cuid())
|
||||
status TaskStatus @default(PENDING)
|
||||
sourceUrl String
|
||||
sourceType String @default("manual")
|
||||
explorationData Json?
|
||||
explorationSummary String?
|
||||
errorMessage String?
|
||||
retryCount Int @default(0)
|
||||
lastRetryAt DateTime?
|
||||
projectId String?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
|
||||
@@index([projectId], map: "idx_task_project_id")
|
||||
@@index([sourceUrl], map: "idx_task_source_url")
|
||||
@@index([status, createdAt], map: "idx_task_status_created")
|
||||
@@map("project_discovery_tasks")
|
||||
}
|
||||
|
||||
model ProjectTag {
|
||||
projectId String
|
||||
tagId String
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([projectId, tagId])
|
||||
@@index([tagId])
|
||||
@@ -59,25 +36,24 @@ model ProjectTag {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
projectDiscoveryTasks ProjectDiscoveryTask[]
|
||||
tags ProjectTag[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
tags ProjectTag[]
|
||||
|
||||
@@index([embedding], map: "idx_project_embedding_cosine")
|
||||
@@index([slug], map: "idx_project_slug")
|
||||
@@ -87,13 +63,13 @@ model Project {
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
category TagCategory @default(FREE_TAG)
|
||||
createdAt DateTime @default(now())
|
||||
projects ProjectTag[]
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
category TagCategory @default(FREE_TAG)
|
||||
createdAt DateTime @default(now())
|
||||
projects ProjectTag[]
|
||||
|
||||
@@index([category], map: "idx_tag_category")
|
||||
@@index([slug], map: "idx_tag_slug")
|
||||
@@ -141,13 +117,6 @@ enum ProjectStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum TagCategory {
|
||||
FIXED_PROJECT_TYPE
|
||||
TECH_STACK
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
type ChatLocale = 'zh' | 'en'
|
||||
type ChatRole = 'USER' | 'ASSISTANT' | 'SYSTEM'
|
||||
type ChatStatus = 'ACTIVE'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: ChatRole
|
||||
contentText: string
|
||||
mode: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface ChatSession {
|
||||
id: string
|
||||
clientId: string
|
||||
locale: ChatLocale
|
||||
title: string
|
||||
status: ChatStatus
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastMessageAt: string
|
||||
}
|
||||
|
||||
interface ChatStoreState {
|
||||
sessions: Map<string, ChatSession>
|
||||
messages: Map<string, ChatMessage[]>
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __agentParkChatStore: ChatStoreState | undefined
|
||||
}
|
||||
|
||||
function getStore(): ChatStoreState {
|
||||
if (!globalThis.__agentParkChatStore) {
|
||||
globalThis.__agentParkChatStore = {
|
||||
sessions: new Map<string, ChatSession>(),
|
||||
messages: new Map<string, ChatMessage[]>(),
|
||||
}
|
||||
}
|
||||
|
||||
return globalThis.__agentParkChatStore
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
export async function createChatSession(input: {
|
||||
clientId: string
|
||||
locale: ChatLocale
|
||||
title?: string
|
||||
}): Promise<ChatSession> {
|
||||
const store = getStore()
|
||||
const now = nowIso()
|
||||
|
||||
const session: ChatSession = {
|
||||
id: crypto.randomUUID(),
|
||||
clientId: input.clientId,
|
||||
locale: input.locale,
|
||||
title: input.title?.trim() || 'New Chat',
|
||||
status: 'ACTIVE',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastMessageAt: now,
|
||||
}
|
||||
|
||||
store.sessions.set(session.id, session)
|
||||
store.messages.set(session.id, [])
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
export async function listChatSessions(input: {
|
||||
clientId: string
|
||||
locale?: ChatLocale
|
||||
limit: number
|
||||
cursor?: string
|
||||
}): Promise<Array<ChatSession & { messages: ChatMessage[] }>> {
|
||||
const store = getStore()
|
||||
|
||||
const ordered = Array.from(store.sessions.values())
|
||||
.filter((session) =>
|
||||
input.locale
|
||||
? session.clientId === input.clientId && session.locale === input.locale
|
||||
: session.clientId === input.clientId
|
||||
)
|
||||
.sort((a, b) => b.lastMessageAt.localeCompare(a.lastMessageAt))
|
||||
|
||||
const cursorIndex = input.cursor ? ordered.findIndex((session) => session.id === input.cursor) : -1
|
||||
const page = cursorIndex >= 0 ? ordered.slice(cursorIndex + 1, cursorIndex + 1 + input.limit) : ordered.slice(0, input.limit)
|
||||
|
||||
return page.map((session) => {
|
||||
const messageList = store.messages.get(session.id) || []
|
||||
const latest = messageList.length > 0 ? [messageList[messageList.length - 1] as ChatMessage] : []
|
||||
return {
|
||||
...session,
|
||||
messages: latest,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function assertSessionOwnership(input: {
|
||||
sessionId: string
|
||||
clientId: string
|
||||
}): Promise<void> {
|
||||
const store = getStore()
|
||||
const session = store.sessions.get(input.sessionId)
|
||||
|
||||
if (!session) {
|
||||
throw new Error('CHAT_SESSION_NOT_FOUND')
|
||||
}
|
||||
|
||||
if (session.clientId !== input.clientId) {
|
||||
throw new Error('CHAT_SESSION_FORBIDDEN')
|
||||
}
|
||||
}
|
||||
|
||||
export async function listChatMessages(input: {
|
||||
sessionId: string
|
||||
limit: number
|
||||
}): Promise<ChatMessage[]> {
|
||||
const store = getStore()
|
||||
const messages = store.messages.get(input.sessionId) || []
|
||||
const normalizedLimit = Math.max(1, input.limit)
|
||||
|
||||
return messages.slice(-normalizedLimit)
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { CheckTaskDuplicatesSchema } from '@/lib/validations'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* 检查 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
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,564 +0,0 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { ProjectInput } from '@/lib/validations'
|
||||
import { generateSlug } from '@/lib/slug'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import {
|
||||
FIXED_PROJECT_TYPE_TAGS,
|
||||
inferProjectTypeSlug,
|
||||
inferTagCategory,
|
||||
type FixedProjectTypeSlug,
|
||||
} from '@/lib/tag-taxonomy'
|
||||
|
||||
const PLURAL_NORMALIZATION_MAP = new Map([
|
||||
['agents', 'agent'],
|
||||
['assistants', 'assistant'],
|
||||
['tools', 'tool'],
|
||||
['frameworks', 'framework'],
|
||||
['models', 'model'],
|
||||
['servers', 'server'],
|
||||
['clients', 'client'],
|
||||
['workflows', 'workflow'],
|
||||
['plugins', 'plugin'],
|
||||
['libraries', 'library'],
|
||||
['datasets', 'dataset'],
|
||||
['platforms', 'platform'],
|
||||
['systems', 'system'],
|
||||
['repositories', 'repository'],
|
||||
['engines', 'engine'],
|
||||
])
|
||||
|
||||
const NOISE_TAG_KEYS = new Set([
|
||||
'ai',
|
||||
'artificial intelligence',
|
||||
'open source',
|
||||
'requires configuration',
|
||||
'requires basics',
|
||||
'low learning curve',
|
||||
'enterprise',
|
||||
'complex deployment',
|
||||
'cloud service',
|
||||
'cross platform',
|
||||
'tutorial',
|
||||
'academic research',
|
||||
'academic resource',
|
||||
'ai research resource',
|
||||
'multi language support',
|
||||
'self hosted',
|
||||
'user notification',
|
||||
'mit license',
|
||||
'apache 2 0',
|
||||
'开源',
|
||||
'需要配置',
|
||||
'需要基础',
|
||||
'低学习成本',
|
||||
'企业级',
|
||||
'复杂部署',
|
||||
'云端服务',
|
||||
'跨平台',
|
||||
'教程',
|
||||
'学术研究',
|
||||
'学术资源',
|
||||
'多语言支持',
|
||||
'自托管',
|
||||
'用户通知',
|
||||
'许可',
|
||||
])
|
||||
|
||||
const TAG_FALLBACK = {
|
||||
name: 'AI开发工具',
|
||||
nameEn: 'AI Development Tool',
|
||||
} as const
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function isAsciiText(value: string): boolean {
|
||||
return /^[\x00-\x7f]+$/.test(value)
|
||||
}
|
||||
|
||||
function canonicalizeTagKey(value: string): string {
|
||||
const normalized = normalizeWhitespace(value)
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.replace(/[+/_&|-]+/g, ' ')
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(' ')
|
||||
.map((word) => PLURAL_NORMALIZATION_MAP.get(word) || word)
|
||||
.join(' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function normalizeIncomingTag(tag: ProjectInput['tags'][number]) {
|
||||
const normalizedName = normalizeWhitespace(tag.name)
|
||||
const normalizedNameEn = normalizeWhitespace(tag.nameEn || '')
|
||||
const resolvedNameEn = normalizedNameEn || (isAsciiText(normalizedName) ? normalizedName : '')
|
||||
const canonicalNameKey = canonicalizeTagKey(normalizedName)
|
||||
const canonicalNameEnKey = canonicalizeTagKey(resolvedNameEn)
|
||||
|
||||
return {
|
||||
name: normalizedName,
|
||||
nameEn: resolvedNameEn || null,
|
||||
canonicalNameKey,
|
||||
canonicalNameEnKey,
|
||||
slug: generateSlug(normalizedName, resolvedNameEn || null),
|
||||
}
|
||||
}
|
||||
|
||||
function isMeaningfulTag(tag: ReturnType<typeof normalizeIncomingTag>): boolean {
|
||||
if (!tag.name) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (NOISE_TAG_KEYS.has(tag.canonicalNameKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tag.canonicalNameEnKey && NOISE_TAG_KEYS.has(tag.canonicalNameEnKey)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||
include: {
|
||||
_count: { select: { projects: true } }
|
||||
}
|
||||
}>
|
||||
|
||||
function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const projectDiff = b._count.projects - a._count.projects
|
||||
if (projectDiff !== 0) {
|
||||
return projectDiff
|
||||
}
|
||||
return a.createdAt.getTime() - b.createdAt.getTime()
|
||||
})[0]!
|
||||
}
|
||||
|
||||
type IncomingNormalizedTag = ReturnType<typeof normalizeIncomingTag>
|
||||
|
||||
type TagLookupMaps = {
|
||||
tagByExactName: Map<string, TagWithProjectCount[]>
|
||||
tagByExactNameEn: Map<string, TagWithProjectCount[]>
|
||||
tagBySlug: Map<string, TagWithProjectCount>
|
||||
tagByCanonicalName: Map<string, TagWithProjectCount[]>
|
||||
tagByCanonicalNameEn: Map<string, TagWithProjectCount[]>
|
||||
}
|
||||
|
||||
function pushTagMapEntry(
|
||||
map: Map<string, TagWithProjectCount[]>,
|
||||
key: string,
|
||||
tag: TagWithProjectCount
|
||||
): void {
|
||||
if (!key) {
|
||||
return
|
||||
}
|
||||
const current = map.get(key)
|
||||
if (current) {
|
||||
current.push(tag)
|
||||
return
|
||||
}
|
||||
map.set(key, [tag])
|
||||
}
|
||||
|
||||
function createTagLookupMaps(tags: TagWithProjectCount[]): TagLookupMaps {
|
||||
const tagByExactName = new Map<string, TagWithProjectCount[]>()
|
||||
const tagByExactNameEn = new Map<string, TagWithProjectCount[]>()
|
||||
const tagBySlug = new Map<string, TagWithProjectCount>()
|
||||
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>()
|
||||
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>()
|
||||
|
||||
for (const tag of tags) {
|
||||
const exactNameKey = normalizeWhitespace(tag.name)
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || '')
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name)
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '')
|
||||
|
||||
pushTagMapEntry(tagByExactName, exactNameKey, tag)
|
||||
pushTagMapEntry(tagByExactNameEn, exactNameEnKey, tag)
|
||||
pushTagMapEntry(tagByCanonicalName, canonicalNameKey, tag)
|
||||
pushTagMapEntry(tagByCanonicalNameEn, canonicalNameEnKey, tag)
|
||||
tagBySlug.set(tag.slug, tag)
|
||||
}
|
||||
|
||||
return {
|
||||
tagByExactName,
|
||||
tagByExactNameEn,
|
||||
tagBySlug,
|
||||
tagByCanonicalName,
|
||||
tagByCanonicalNameEn,
|
||||
}
|
||||
}
|
||||
|
||||
function addTagToLookupMaps(lookups: TagLookupMaps, tag: TagWithProjectCount): void {
|
||||
const exactNameKey = normalizeWhitespace(tag.name)
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || '')
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name)
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || '')
|
||||
|
||||
pushTagMapEntry(lookups.tagByExactName, exactNameKey, tag)
|
||||
pushTagMapEntry(lookups.tagByExactNameEn, exactNameEnKey, tag)
|
||||
pushTagMapEntry(lookups.tagByCanonicalName, canonicalNameKey, tag)
|
||||
pushTagMapEntry(lookups.tagByCanonicalNameEn, canonicalNameEnKey, tag)
|
||||
lookups.tagBySlug.set(tag.slug, tag)
|
||||
}
|
||||
|
||||
async function getCandidateTags(incomingTags: IncomingNormalizedTag[]): Promise<TagWithProjectCount[]> {
|
||||
const nameValues = Array.from(
|
||||
new Set(incomingTags.map((tag) => normalizeWhitespace(tag.name)).filter((name) => name.length > 0))
|
||||
)
|
||||
const slugValues = Array.from(
|
||||
new Set(incomingTags.map((tag) => tag.slug).filter((slug) => slug.length > 0))
|
||||
)
|
||||
const nameEnValues = Array.from(
|
||||
new Set(
|
||||
incomingTags
|
||||
.map((tag) => normalizeWhitespace(tag.nameEn || ''))
|
||||
.filter((nameEn) => nameEn.length > 0)
|
||||
)
|
||||
)
|
||||
|
||||
const whereOr: Prisma.TagWhereInput[] = []
|
||||
if (nameValues.length > 0) {
|
||||
whereOr.push({ name: { in: nameValues } })
|
||||
}
|
||||
if (slugValues.length > 0) {
|
||||
whereOr.push({ slug: { in: slugValues } })
|
||||
}
|
||||
if (nameEnValues.length > 0) {
|
||||
whereOr.push({ nameEn: { in: nameEnValues } })
|
||||
}
|
||||
|
||||
if (whereOr.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return prisma.tag.findMany({
|
||||
where: {
|
||||
OR: whereOr,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
||||
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn)
|
||||
const fallbackCategory = inferTagCategory({
|
||||
slug: fallbackSlug,
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
})
|
||||
const fallbackTag = await prisma.tag.upsert({
|
||||
where: { slug: fallbackSlug },
|
||||
update: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
create: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
slug: fallbackSlug,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return fallbackTag
|
||||
}
|
||||
|
||||
const FIXED_PROJECT_TYPE_MAP = new Map(
|
||||
FIXED_PROJECT_TYPE_TAGS.map((tag) => [tag.slug, tag] as const)
|
||||
)
|
||||
|
||||
export async function ensureFixedProjectTypeTag(
|
||||
slug: FixedProjectTypeSlug
|
||||
): Promise<TagWithProjectCount> {
|
||||
const fixedTag = FIXED_PROJECT_TYPE_MAP.get(slug)
|
||||
if (!fixedTag) {
|
||||
throw new Error(`Unsupported fixed project type slug: ${slug}`)
|
||||
}
|
||||
|
||||
return prisma.tag.upsert({
|
||||
where: { slug: fixedTag.slug },
|
||||
update: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
category: 'FIXED_PROJECT_TYPE',
|
||||
},
|
||||
create: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
slug: fixedTag.slug,
|
||||
category: 'FIXED_PROJECT_TYPE',
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveFixedProjectTypeTag(
|
||||
projectData: Pick<ProjectInput, 'name' | 'nameEn' | 'description' | 'descriptionEn' | 'tags'>
|
||||
): Promise<TagWithProjectCount> {
|
||||
const projectTypeSlug = inferProjectTypeSlug({
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn,
|
||||
tags: projectData.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
nameEn: tag.nameEn,
|
||||
slug: generateSlug(tag.name, tag.nameEn || null),
|
||||
})),
|
||||
})
|
||||
|
||||
return ensureFixedProjectTypeTag(projectTypeSlug)
|
||||
}
|
||||
|
||||
/**
|
||||
* 多级去重策略:查找已存在的项目
|
||||
*
|
||||
* 优先级:
|
||||
* 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']) {
|
||||
const meaningfulTags = tags
|
||||
.map((tag) => normalizeIncomingTag(tag))
|
||||
.filter((tag) => isMeaningfulTag(tag))
|
||||
|
||||
const normalizedIncomingTags = Array.from(
|
||||
new Map(
|
||||
meaningfulTags.map((normalized) => {
|
||||
return [
|
||||
normalized.canonicalNameEnKey || normalized.canonicalNameKey || normalized.name,
|
||||
normalized,
|
||||
] as const
|
||||
})
|
||||
).values()
|
||||
)
|
||||
|
||||
if (normalizedIncomingTags.length === 0) {
|
||||
return [await getFallbackTag()]
|
||||
}
|
||||
|
||||
const candidateTags = await getCandidateTags(normalizedIncomingTags)
|
||||
const lookups = createTagLookupMaps(candidateTags)
|
||||
|
||||
const resolvedTags: TagWithProjectCount[] = []
|
||||
|
||||
for (const incomingTag of normalizedIncomingTags) {
|
||||
const exactNameCandidates = lookups.tagByExactName.get(incomingTag.name) || []
|
||||
let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null
|
||||
|
||||
if (!matchedTag && incomingTag.nameEn) {
|
||||
const exactNameEnCandidates =
|
||||
lookups.tagByExactNameEn.get(incomingTag.nameEn) || []
|
||||
if (exactNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(exactNameEnCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameKey) {
|
||||
const canonicalNameCandidates =
|
||||
lookups.tagByCanonicalName.get(incomingTag.canonicalNameKey) || []
|
||||
if (canonicalNameCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameEnKey) {
|
||||
const canonicalNameEnCandidates =
|
||||
lookups.tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || []
|
||||
if (canonicalNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameEnCandidates)
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag) {
|
||||
matchedTag = lookups.tagBySlug.get(incomingTag.slug) || null
|
||||
}
|
||||
|
||||
if (matchedTag) {
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
})
|
||||
const shouldUpdateNameEn = incomingTag.nameEn && !matchedTag.nameEn
|
||||
const shouldUpdateCategory =
|
||||
matchedTag.category !== inferredCategory &&
|
||||
['FREE_TAG', 'RESOURCE_TYPE', 'PROTOCOL_INTERFACE'].includes(matchedTag.category)
|
||||
|
||||
if (shouldUpdateNameEn || shouldUpdateCategory) {
|
||||
const updatedTag = await prisma.tag.update({
|
||||
where: { id: matchedTag.id },
|
||||
data: {
|
||||
...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}),
|
||||
...(shouldUpdateCategory ? { category: inferredCategory } : {}),
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
matchedTag = updatedTag
|
||||
addTagToLookupMaps(lookups, matchedTag)
|
||||
}
|
||||
|
||||
resolvedTags.push(matchedTag)
|
||||
continue
|
||||
}
|
||||
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
})
|
||||
|
||||
try {
|
||||
const createdTag = await prisma.tag.create({
|
||||
data: {
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
slug: incomingTag.slug,
|
||||
category: inferredCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
resolvedTags.push(createdTag)
|
||||
addTagToLookupMaps(lookups, createdTag)
|
||||
} catch {
|
||||
const fallbackTag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: incomingTag.name },
|
||||
{ slug: incomingTag.slug },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (fallbackTag) {
|
||||
resolvedTags.push(fallbackTag)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Failed to resolve tag: ${incomingTag.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Map(resolvedTags.map((tag) => [tag.id, tag])).values()
|
||||
)
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { generateSlug } from '@/lib/slug'
|
||||
import {
|
||||
ProjectInputSchema,
|
||||
type ProjectInput,
|
||||
} from '@/lib/validations'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
import {
|
||||
findExistingProject,
|
||||
resolveFixedProjectTypeTag,
|
||||
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密钥
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
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 [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
|
||||
upsertTags(projectData.tags),
|
||||
resolveFixedProjectTypeTag(projectData),
|
||||
])
|
||||
const tagConnections = Array.from(
|
||||
new Map(
|
||||
[...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])
|
||||
).values()
|
||||
)
|
||||
|
||||
// 生成slug
|
||||
const slug = generateSlug(projectData.name, projectData.nameEn)
|
||||
|
||||
// 使用事务确保项目创建/更新和任务状态更新的原子性
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
let projectId: string
|
||||
|
||||
if (existingProject) {
|
||||
// 更新现有项目
|
||||
console.warn(
|
||||
`[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.warn(
|
||||
`[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.warn(
|
||||
`[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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { UpdateDiscoveryTaskSchema, TaskStatus } from '@/lib/validations'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* 有效的任务状态转换规则
|
||||
* PENDING -> IN_PROGRESS
|
||||
* IN_PROGRESS -> COMPLETED | FAILED
|
||||
* FAILED -> PENDING | IN_PROGRESS (允许重试,可直接重试或重置后重试)
|
||||
* COMPLETED -> (终态,不允许转换)
|
||||
*/
|
||||
const VALID_STATUS_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
||||
PENDING: ['IN_PROGRESS'],
|
||||
IN_PROGRESS: ['COMPLETED', 'FAILED'],
|
||||
COMPLETED: [],
|
||||
FAILED: ['PENDING', 'IN_PROGRESS'],
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证状态转换是否合法
|
||||
*/
|
||||
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密钥
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
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.warn(`[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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { BatchResetTasksSchema } from '@/lib/validations'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* POST /api/discovery/tasks/batch-reset
|
||||
* 批量重置任务状态为 PENDING
|
||||
*
|
||||
* 用于 n8n 探索流程失败后重置任务,支持两种模式:
|
||||
* 1. 按任务 ID 列表重置:{ taskIds: ["id1", "id2"] }
|
||||
* 2. 按状态筛选重置:{ statuses: ["IN_PROGRESS", "FAILED"] }
|
||||
* 不传 statuses 则默认重置 IN_PROGRESS 和 FAILED
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = BatchResetTasksSchema.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, taskIds, statuses } = validation.data
|
||||
|
||||
// 验证API密钥
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const where = taskIds
|
||||
? { id: { in: taskIds } }
|
||||
: { status: { in: statuses || ['IN_PROGRESS', 'FAILED'] } }
|
||||
|
||||
// 批量更新任务状态
|
||||
const result = await prisma.projectDiscoveryTask.updateMany({
|
||||
where,
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
})
|
||||
|
||||
console.warn(
|
||||
`[Discovery] Batch reset ${result.count} tasks to PENDING. Condition: ${JSON.stringify(where)}`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
reset: result.count,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Discovery] Error batch resetting tasks:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
CreateDiscoveryTaskSchema,
|
||||
GetDiscoveryTasksQuerySchema,
|
||||
} from '@/lib/validations'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* 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密钥
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
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.warn(
|
||||
`[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 {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// 验证 API Key(只读权限)
|
||||
// 支持两种方式:1. 请求头 x-api-key 2. 查询参数 apiKey
|
||||
const apiKey = request.headers.get('x-api-key') || searchParams.get('apiKey')
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
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
|
||||
|
||||
// 构建查询条件:支持单状态或多状态(逗号分隔)
|
||||
let whereClause = {}
|
||||
if (status) {
|
||||
// 支持单状态 (?status=PENDING) 或多状态 (?status=PENDING,FAILED)
|
||||
if (Array.isArray(status)) {
|
||||
whereClause = { status: { in: status } }
|
||||
} else {
|
||||
whereClause = { status }
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = await prisma.projectDiscoveryTask.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
})
|
||||
|
||||
const total = await prisma.projectDiscoveryTask.count({
|
||||
where: whereClause,
|
||||
})
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +1,14 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/[slug]
|
||||
*
|
||||
* 根据项目的 slug 删除项目及其所有关联数据
|
||||
*
|
||||
* 由于数据库 schema 配置了 onDelete: Cascade,
|
||||
* 删除项目时会自动删除:
|
||||
* - 该项目的所有外部链接(ExternalLink)
|
||||
* - 该项目的所有标签关联(ProjectTag)
|
||||
*
|
||||
* 注意:Tag 本身不会被删除,只会删除项目与标签的关联关系
|
||||
*/
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
|
||||
// Verify API Key
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if project exists
|
||||
const existingProject = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
links: true,
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!existingProject) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Not Found',
|
||||
details: [`Project with slug "${slug}" not found`],
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
// Delete project (cascade delete will handle links and project_tags)
|
||||
await prisma.project.delete({
|
||||
where: { slug },
|
||||
})
|
||||
|
||||
console.warn(
|
||||
`[API] Deleted project "${existingProject.name}" (slug: ${slug}, id: ${existingProject.id})`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Project deleted successfully',
|
||||
data: {
|
||||
project: {
|
||||
id: existingProject.id,
|
||||
name: existingProject.name,
|
||||
nameEn: existingProject.nameEn,
|
||||
slug: existingProject.slug,
|
||||
},
|
||||
deleted: {
|
||||
linksCount: existingProject.links.length,
|
||||
tagsCount: existingProject.tags.length,
|
||||
},
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[API] Error deleting project:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* GET /api/projects/[slug]
|
||||
*
|
||||
* 根据项目的 slug 获取项目详情
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
) {
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params
|
||||
const { slug } = await params;
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
@@ -118,17 +20,17 @@ export async function GET(
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Not Found',
|
||||
error: "Not Found",
|
||||
details: [`Project with slug "${slug}" not found`],
|
||||
},
|
||||
{ status: 404 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Transform response to match frontend structure
|
||||
@@ -140,21 +42,21 @@ export async function GET(
|
||||
nameEn: pt.tag.nameEn,
|
||||
slug: pt.tag.slug,
|
||||
})),
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: transformedProject,
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[API] Error fetching project:', error)
|
||||
console.error("[API] Error fetching project:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* 去重检查请求 Schema
|
||||
*/
|
||||
const CheckDuplicatesSchema = z.object({
|
||||
apiKey: z.string(),
|
||||
projects: z.array(
|
||||
z.object({
|
||||
githubUrl: z.string().url().optional(),
|
||||
huggingfaceUrl: z.string().url().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
).min(1).max(100),
|
||||
})
|
||||
|
||||
/**
|
||||
* 匹配类型
|
||||
*/
|
||||
type MatchType =
|
||||
| 'GITHUB_URL'
|
||||
| 'HUGGINGFACE_URL'
|
||||
| 'WEBSITE_URL'
|
||||
| 'SLUG'
|
||||
| 'NONE'
|
||||
|
||||
/**
|
||||
* 检查结果
|
||||
*/
|
||||
interface CheckResult {
|
||||
githubUrl?: string
|
||||
huggingfaceUrl?: string
|
||||
websiteUrl?: string
|
||||
slug?: string
|
||||
exists: boolean
|
||||
matchType: MatchType
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
type CheckProjectInput = {
|
||||
githubUrl?: string
|
||||
huggingfaceUrl?: string
|
||||
websiteUrl?: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
type ProjectSummary = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
type DuplicateCheckMaps = {
|
||||
githubMap: Map<string, ProjectSummary>
|
||||
huggingfaceMap: Map<string, ProjectSummary>
|
||||
websiteMap: Map<string, ProjectSummary>
|
||||
slugMap: Map<string, ProjectSummary>
|
||||
}
|
||||
|
||||
function buildLookupKey(project: CheckProjectInput): string {
|
||||
return [
|
||||
project.githubUrl || '',
|
||||
project.huggingfaceUrl || '',
|
||||
project.websiteUrl || '',
|
||||
project.slug || '',
|
||||
].join('|')
|
||||
}
|
||||
|
||||
function toExternalLinkMap(
|
||||
rows: Array<{ url: string; project: ProjectSummary }>
|
||||
): Map<string, ProjectSummary> {
|
||||
const map = new Map<string, ProjectSummary>()
|
||||
for (const row of rows) {
|
||||
if (!map.has(row.url)) {
|
||||
map.set(row.url, row.project)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function toSlugMap(
|
||||
rows: Array<{ slug: string; id: string; name: string }>
|
||||
): Map<string, ProjectSummary> {
|
||||
const map = new Map<string, ProjectSummary>()
|
||||
for (const row of rows) {
|
||||
map.set(row.slug, { id: row.id, name: row.name })
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function buildDuplicateCheckMaps(
|
||||
projects: CheckProjectInput[]
|
||||
): Promise<DuplicateCheckMaps> {
|
||||
const githubUrls = Array.from(
|
||||
new Set(
|
||||
projects
|
||||
.map((project) => project.githubUrl?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
)
|
||||
)
|
||||
const huggingfaceUrls = Array.from(
|
||||
new Set(
|
||||
projects
|
||||
.map((project) => project.huggingfaceUrl?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
)
|
||||
)
|
||||
const websiteUrls = Array.from(
|
||||
new Set(
|
||||
projects
|
||||
.map((project) => project.websiteUrl?.trim())
|
||||
.filter((url): url is string => Boolean(url))
|
||||
)
|
||||
)
|
||||
const slugs = Array.from(
|
||||
new Set(
|
||||
projects
|
||||
.map((project) => project.slug?.trim())
|
||||
.filter((slug): slug is string => Boolean(slug))
|
||||
)
|
||||
)
|
||||
|
||||
const [githubRows, huggingfaceRows, websiteRows, slugRows] = await Promise.all([
|
||||
githubUrls.length > 0
|
||||
? prisma.externalLink.findMany({
|
||||
where: {
|
||||
type: 'GITHUB',
|
||||
url: {
|
||||
in: githubUrls,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
url: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
huggingfaceUrls.length > 0
|
||||
? prisma.externalLink.findMany({
|
||||
where: {
|
||||
type: 'HUGGINGFACE',
|
||||
url: {
|
||||
in: huggingfaceUrls,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
url: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
websiteUrls.length > 0
|
||||
? prisma.externalLink.findMany({
|
||||
where: {
|
||||
type: 'WEBSITE',
|
||||
url: {
|
||||
in: websiteUrls,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
url: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
slugs.length > 0
|
||||
? prisma.project.findMany({
|
||||
where: {
|
||||
slug: {
|
||||
in: slugs,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
])
|
||||
|
||||
return {
|
||||
githubMap: toExternalLinkMap(githubRows),
|
||||
huggingfaceMap: toExternalLinkMap(huggingfaceRows),
|
||||
websiteMap: toExternalLinkMap(websiteRows),
|
||||
slugMap: toSlugMap(slugRows),
|
||||
}
|
||||
}
|
||||
|
||||
function checkProjectExists(
|
||||
project: CheckProjectInput,
|
||||
duplicateMaps: DuplicateCheckMaps
|
||||
): CheckResult {
|
||||
if (project.githubUrl) {
|
||||
const existingByGithub = duplicateMaps.githubMap.get(project.githubUrl.trim())
|
||||
if (existingByGithub) {
|
||||
return {
|
||||
githubUrl: project.githubUrl,
|
||||
exists: true,
|
||||
matchType: 'GITHUB_URL',
|
||||
projectId: existingByGithub.id,
|
||||
projectName: existingByGithub.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (project.huggingfaceUrl) {
|
||||
const existingByHuggingFace = duplicateMaps.huggingfaceMap.get(project.huggingfaceUrl.trim())
|
||||
if (existingByHuggingFace) {
|
||||
return {
|
||||
huggingfaceUrl: project.huggingfaceUrl,
|
||||
exists: true,
|
||||
matchType: 'HUGGINGFACE_URL',
|
||||
projectId: existingByHuggingFace.id,
|
||||
projectName: existingByHuggingFace.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (project.websiteUrl) {
|
||||
const existingByWebsite = duplicateMaps.websiteMap.get(project.websiteUrl.trim())
|
||||
if (existingByWebsite) {
|
||||
return {
|
||||
websiteUrl: project.websiteUrl,
|
||||
exists: true,
|
||||
matchType: 'WEBSITE_URL',
|
||||
projectId: existingByWebsite.id,
|
||||
projectName: existingByWebsite.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (project.slug) {
|
||||
const existingBySlug = duplicateMaps.slugMap.get(project.slug.trim())
|
||||
if (existingBySlug) {
|
||||
return {
|
||||
slug: project.slug,
|
||||
exists: true,
|
||||
matchType: 'SLUG',
|
||||
projectId: existingBySlug.id,
|
||||
projectName: existingBySlug.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
githubUrl: project.githubUrl,
|
||||
huggingfaceUrl: project.huggingfaceUrl,
|
||||
websiteUrl: project.websiteUrl,
|
||||
slug: project.slug,
|
||||
exists: false,
|
||||
matchType: 'NONE',
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate payload
|
||||
const validationResult = CheckDuplicatesSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: validationResult.error.errors.map((e) => e.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const payload = validationResult.data
|
||||
|
||||
// Verify API Key
|
||||
if (!isValidApiKey(payload.apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const duplicateMaps = await buildDuplicateCheckMaps(payload.projects)
|
||||
const cachedResults = new Map<string, CheckResult>()
|
||||
|
||||
const results = payload.projects.map((project) => {
|
||||
const lookupKey = buildLookupKey(project)
|
||||
const cached = cachedResults.get(lookupKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const result = checkProjectExists(project, duplicateMaps)
|
||||
cachedResults.set(lookupKey, result)
|
||||
return result
|
||||
})
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
total: results.length,
|
||||
exists: results.filter((r) => r.exists).length,
|
||||
new: results.filter((r) => !r.exists).length,
|
||||
breakdown: {
|
||||
githubUrl: results.filter((r) => r.matchType === 'GITHUB_URL').length,
|
||||
huggingfaceUrl: results.filter(
|
||||
(r) => r.matchType === 'HUGGINGFACE_URL'
|
||||
).length,
|
||||
websiteUrl: results.filter((r) => r.matchType === 'WEBSITE_URL')
|
||||
.length,
|
||||
slug: results.filter((r) => r.matchType === 'SLUG').length,
|
||||
},
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
console.warn(
|
||||
`[CheckDuplicates] Checked ${stats.total} projects in ${duration}ms: ${stats.exists} exist, ${stats.new} new`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
stats,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[CheckDuplicates] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { ProjectStatus, LinkType } from '@prisma/client'
|
||||
import { isValidApiKey } from '@/lib/auth'
|
||||
import {
|
||||
WebhookPayloadSchema,
|
||||
ProjectIngestionInputSchema,
|
||||
type WebhookPayload,
|
||||
type ProjectIngestionInput,
|
||||
} from '@/lib/validations'
|
||||
import {
|
||||
findExistingProject,
|
||||
resolveFixedProjectTypeTag,
|
||||
upsertTags,
|
||||
} from '../../discovery/lib/discovery-service'
|
||||
import { generateSlug } from '@/lib/slug'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate payload
|
||||
const validationResult = WebhookPayloadSchema.safeParse(body)
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
details: validationResult.error.errors.map((e) => e.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const payload = validationResult.data as WebhookPayload
|
||||
|
||||
// Verify API Key using timing-safe comparison to prevent timing attacks
|
||||
if (!isValidApiKey(payload.apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Process projects with partial success mode
|
||||
const results = {
|
||||
processed: payload.projects.length,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
failed: 0,
|
||||
errors: [] as Array<{
|
||||
index: number
|
||||
field: string
|
||||
message: string
|
||||
value: unknown
|
||||
}>,
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.projects.length; i++) {
|
||||
const projectData = payload.projects[i]
|
||||
|
||||
// Validate individual project
|
||||
const projectValidation = ProjectIngestionInputSchema.safeParse(projectData)
|
||||
|
||||
if (!projectValidation.success) {
|
||||
results.failed++
|
||||
const firstError = projectValidation.error.errors[0]
|
||||
results.errors.push({
|
||||
index: i,
|
||||
field: firstError?.path.join('.') || 'unknown',
|
||||
message: firstError?.message || 'Validation failed',
|
||||
value: projectData,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const validProject = projectValidation.data as ProjectIngestionInput
|
||||
|
||||
// 多级去重:查找已存在的项目
|
||||
const existingProject = await findExistingProject(validProject)
|
||||
|
||||
// 标签规范化与自动复用(防止同义标签持续膨胀)
|
||||
const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
|
||||
upsertTags(validProject.tags),
|
||||
resolveFixedProjectTypeTag(validProject),
|
||||
])
|
||||
const tagConnections = Array.from(
|
||||
new Map(
|
||||
[...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])
|
||||
).values()
|
||||
)
|
||||
|
||||
// Generate slug for project
|
||||
const slug = generateSlug(validProject.name, validProject.nameEn)
|
||||
|
||||
if (existingProject) {
|
||||
// Update existing project
|
||||
const matchMethod = existingProject.slug === slug
|
||||
? 'slug'
|
||||
: validProject.links.some(
|
||||
(l) =>
|
||||
existingProject.links.some((el: { url: string }) => el.url === l.url)
|
||||
)
|
||||
? 'url'
|
||||
: 'unknown'
|
||||
|
||||
console.warn(
|
||||
`[Webhook] Updating project "${validProject.name}" (matched by ${matchMethod}, id: ${existingProject.id})`
|
||||
)
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Update tags (delete old ones, create new ones)
|
||||
await tx.projectTag.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
})
|
||||
|
||||
await tx.project.update({
|
||||
where: { id: existingProject.id },
|
||||
data: {
|
||||
name: validProject.name,
|
||||
nameEn: validProject.nameEn || null,
|
||||
description: validProject.description,
|
||||
descriptionEn: validProject.descriptionEn || null,
|
||||
content: validProject.content || null,
|
||||
contentEn: validProject.contentEn || null,
|
||||
status: validProject.status as ProjectStatus,
|
||||
source: validProject.source || null,
|
||||
tags: {
|
||||
create: tagConnections.map((t) => ({
|
||||
tag: { connect: { id: t.id } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Update links (delete old ones, create new ones)
|
||||
await tx.externalLink.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
})
|
||||
|
||||
await tx.externalLink.createMany({
|
||||
data: validProject.links.map((link) => ({
|
||||
type: link.type as LinkType,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
projectId: existingProject.id,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
results.updated++
|
||||
} else {
|
||||
// Create new project
|
||||
console.warn(
|
||||
`[Webhook] Creating new project "${validProject.name}" (slug: ${slug})`
|
||||
)
|
||||
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
name: validProject.name,
|
||||
nameEn: validProject.nameEn || null,
|
||||
slug,
|
||||
description: validProject.description,
|
||||
descriptionEn: validProject.descriptionEn || null,
|
||||
content: validProject.content || null,
|
||||
contentEn: validProject.contentEn || null,
|
||||
status: validProject.status as ProjectStatus,
|
||||
source: validProject.source || null,
|
||||
tags: {
|
||||
create: tagConnections.map((t) => ({
|
||||
tag: { connect: { id: t.id } },
|
||||
})),
|
||||
},
|
||||
links: {
|
||||
create: validProject.links.map((link) => ({
|
||||
type: link.type as LinkType,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
results.created++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Webhook] Error processing project at index ${i}:`, error)
|
||||
results.failed++
|
||||
results.errors.push({
|
||||
index: i,
|
||||
field: 'general',
|
||||
message: 'Failed to process project. Please check the server logs.',
|
||||
value: process.env.NODE_ENV === 'development' ? projectData : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// Log request
|
||||
console.warn(
|
||||
`[Webhook] Processed ${results.processed} projects in ${duration}ms: ${results.created} created, ${results.updated} updated, ${results.failed} failed`
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...results,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Webhook] Error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AIEventInputSchema, ProjectInputSchema } from './validations';
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ProjectInputSchema } from "./validations";
|
||||
|
||||
describe('AIEventInputSchema', () => {
|
||||
const validEvent = {
|
||||
title: 'GPT-4 发布',
|
||||
eventDate: '2023-03-14T00:00:00Z',
|
||||
description: 'OpenAI 发布多模态大语言模型',
|
||||
imageUrl: 'https://example.com/gpt4.jpg',
|
||||
};
|
||||
|
||||
it('should validate valid event', () => {
|
||||
expect(() => AIEventInputSchema.parse(validEvent)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should accept event with optional English fields', () => {
|
||||
const eventWithEn = {
|
||||
...validEvent,
|
||||
titleEn: 'GPT-4 Release',
|
||||
descriptionEn: 'OpenAI launches multimodal LLM',
|
||||
sourceUrl: 'https://openai.com/blog/gpt-4',
|
||||
};
|
||||
expect(() => AIEventInputSchema.parse(eventWithEn)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject empty title', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: '' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject title exceeding 200 characters', () => {
|
||||
const longTitle = 'A'.repeat(201);
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, title: longTitle }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject description shorter than 10 characters', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: '太短' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject description exceeding 500 characters', () => {
|
||||
const longDesc = 'A'.repeat(501);
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, description: longDesc }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid eventDate format', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, eventDate: '2023-03-14' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid imageUrl', () => {
|
||||
expect(() => AIEventInputSchema.parse({ ...validEvent, imageUrl: 'not-a-url' }))
|
||||
.toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid sourceUrl format', () => {
|
||||
expect(() => AIEventInputSchema.parse({
|
||||
...validEvent,
|
||||
sourceUrl: 'not-a-url'
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProjectInputSchema', () => {
|
||||
describe("ProjectInputSchema", () => {
|
||||
const baseProjectInput = {
|
||||
name: 'Agent Park',
|
||||
description: 'A curated list of practical AI agent tools.',
|
||||
tags: [{ name: 'ai-agent' }],
|
||||
links: [{ type: 'GITHUB' as const, url: 'https://github.com/example/repo' }],
|
||||
name: "Agent Park",
|
||||
description: "A curated list of practical AI agent tools.",
|
||||
tags: [{ name: "ai-agent" }],
|
||||
links: [{ type: "GITHUB" as const, url: "https://github.com/example/repo" }],
|
||||
};
|
||||
|
||||
it('should allow more than 10 tags', () => {
|
||||
it("should allow more than 10 tags", () => {
|
||||
const manyTags = Array.from({ length: 20 }, (_, index) => ({
|
||||
name: `tag-${index + 1}`,
|
||||
}));
|
||||
@@ -84,7 +22,7 @@ describe('ProjectInputSchema', () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should still require at least one tag', () => {
|
||||
it("should still require at least one tag", () => {
|
||||
expect(() =>
|
||||
ProjectInputSchema.parse({
|
||||
...baseProjectInput,
|
||||
|
||||
+107
-355
@@ -53,108 +53,14 @@ export const ProjectInputSchema = ProjectBaseSchema.extend({
|
||||
links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10),
|
||||
});
|
||||
|
||||
export const ProjectIngestionInputSchema = ProjectBaseSchema.extend({
|
||||
tags: z.array(TagSchema).min(1, "At least one tag is required"),
|
||||
links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Webhook Schemas
|
||||
// Internal API Schemas
|
||||
// ================================
|
||||
|
||||
export const WebhookAuthSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
});
|
||||
|
||||
export const WebhookPayloadSchema = WebhookAuthSchema.extend({
|
||||
projects: z.array(ProjectIngestionInputSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Discovery Task Schemas
|
||||
// ================================
|
||||
|
||||
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
|
||||
|
||||
const JsonValueSchema: z.ZodType<unknown> = z.lazy(() =>
|
||||
z.union([
|
||||
z.string(),
|
||||
z.number(),
|
||||
z.boolean(),
|
||||
z.null(),
|
||||
z.array(JsonValueSchema),
|
||||
z.record(JsonValueSchema),
|
||||
])
|
||||
);
|
||||
|
||||
export const JsonObjectSchema = z.record(JsonValueSchema);
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
export const UpdateDiscoveryTaskSchema = z.object({
|
||||
apiKey: z.string().min(32),
|
||||
status: TaskStatusEnum,
|
||||
explorationData: JsonObjectSchema.optional(),
|
||||
explorationSummary: z.string().max(1000).optional(),
|
||||
errorMessage: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
export const GetDiscoveryTasksQuerySchema = z.object({
|
||||
status: z.string().optional().transform((val) => {
|
||||
// 支持单状态或多状态(逗号分隔),如 "PENDING" 或 "PENDING,FAILED"
|
||||
if (!val) return undefined;
|
||||
const statuses = val.split(',').map(s => s.trim() as TaskStatus).filter(Boolean);
|
||||
return statuses.length === 1 ? statuses[0] : statuses;
|
||||
}),
|
||||
limit: z.coerce.number().int().positive().max(100).default(10),
|
||||
offset: z.coerce.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量重置任务 Schema
|
||||
* 支持两种模式:按 ID 列表重置 或 按状态筛选重置
|
||||
*/
|
||||
export const BatchResetTasksSchema = WebhookAuthSchema.extend({
|
||||
// 模式1: 指定任务 ID 列表
|
||||
taskIds: z.array(z.string()).optional(),
|
||||
// 模式2: 按状态筛选(不传则默认重置 IN_PROGRESS 和 FAILED)
|
||||
statuses: z.array(TaskStatusEnum).optional(),
|
||||
}).refine((data) => data.taskIds || data.statuses, {
|
||||
message: "必须提供 taskIds 或 statuses 之一",
|
||||
});
|
||||
|
||||
/**
|
||||
* 检查任务去重 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(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// AI Timeline Schemas
|
||||
// ================================
|
||||
|
||||
export const AIEventInputSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
titleEn: z.string().max(200).optional(),
|
||||
eventDate: z.string().datetime({ offset: true }),
|
||||
description: z.string().min(10).max(500),
|
||||
descriptionEn: z.string().max(500).optional(),
|
||||
imageUrl: z.string().url().max(2000),
|
||||
sourceUrl: z.string().url().max(2000).optional(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Signals Schemas
|
||||
// ================================
|
||||
@@ -168,13 +74,7 @@ export const SignalSourceEnum = z.enum([
|
||||
"product_hunt",
|
||||
]);
|
||||
|
||||
export const SignalSectionStyleEnum = z.enum([
|
||||
"focus",
|
||||
"debate",
|
||||
"evidence",
|
||||
"action",
|
||||
"risk",
|
||||
]);
|
||||
export const SignalSectionStyleEnum = z.enum(["focus", "debate", "evidence", "action", "risk"]);
|
||||
|
||||
export const SignalSectionSchema = z.object({
|
||||
id: z.string().min(1).max(60),
|
||||
@@ -232,134 +132,6 @@ export const ProjectQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Chat Schemas
|
||||
// ================================
|
||||
|
||||
export const ChatModeEnum = z.enum(["stream", "job"]);
|
||||
export const ChatRoleEnum = z.enum(["user", "assistant", "system"]);
|
||||
export const ChatBlockTypeEnum = z.enum([
|
||||
"text",
|
||||
"mermaid",
|
||||
"excalidraw_image",
|
||||
"code",
|
||||
"table",
|
||||
]);
|
||||
export const ChatEventTypeEnum = z.enum([
|
||||
"start",
|
||||
"progress",
|
||||
"delta",
|
||||
"artifact",
|
||||
"final",
|
||||
"error",
|
||||
]);
|
||||
export const ChatJobStatusEnum = z.enum(["queued", "running", "completed", "failed"]);
|
||||
export const ChatFeedbackRatingEnum = z.enum(["helpful", "unhelpful"]);
|
||||
|
||||
export const ChatCapabilitySchema = z.object({
|
||||
allowMermaid: z.boolean().optional().default(true),
|
||||
allowExcalidrawImage: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export const ChatCitationSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
url: z.string().url().max(2000),
|
||||
snippet: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
export const ChatMessageBlockSchema = z
|
||||
.object({
|
||||
type: ChatBlockTypeEnum,
|
||||
title: z.string().max(200).optional(),
|
||||
content: z.string().max(50000).optional(),
|
||||
url: z.string().url().max(2000).optional(),
|
||||
language: z.string().max(50).optional(),
|
||||
rows: z.array(z.array(z.string().max(500))).optional(),
|
||||
headers: z.array(z.string().max(200)).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if ((data.type === "text" || data.type === "mermaid" || data.type === "code") && !data.content) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["content"],
|
||||
message: `content is required for block type: ${data.type}`,
|
||||
});
|
||||
}
|
||||
if (data.type === "excalidraw_image" && !data.url) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["url"],
|
||||
message: "url is required for block type: excalidraw_image",
|
||||
});
|
||||
}
|
||||
if (data.type === "table" && (!data.headers || !data.rows)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["rows"],
|
||||
message: "headers and rows are required for block type: table",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const ChatAssistantMessageSchema = z.object({
|
||||
id: z.string().min(1).max(100).optional(),
|
||||
role: z.literal("assistant").default("assistant"),
|
||||
blocks: z.array(ChatMessageBlockSchema).default([]),
|
||||
citations: z.array(ChatCitationSchema).optional().default([]),
|
||||
meta: z.record(z.unknown()).optional(),
|
||||
rawN8nPayload: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export const ChatMessageRequestSchema = z.object({
|
||||
sessionId: z.string().min(1).max(100).optional(),
|
||||
clientId: z.string().min(8).max(100),
|
||||
locale: z.enum(["zh", "en"]),
|
||||
mode: ChatModeEnum.default("job"),
|
||||
message: z.string().min(1).max(8000),
|
||||
capabilities: ChatCapabilitySchema.optional().default({}),
|
||||
context: JsonObjectSchema.optional(),
|
||||
});
|
||||
|
||||
export const ChatSessionQuerySchema = z.object({
|
||||
clientId: z.string().min(8).max(100),
|
||||
locale: z.enum(["zh", "en"]).optional(),
|
||||
limit: z.coerce.number().int().positive().max(50).default(20),
|
||||
cursor: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const ChatJobStatusQuerySchema = z.object({
|
||||
clientId: z.string().min(8).max(100),
|
||||
sessionId: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export const ChatFeedbackRequestSchema = z.object({
|
||||
clientId: z.string().min(8).max(100),
|
||||
sessionId: z.string().min(1).max(100),
|
||||
messageId: z.string().min(1).max(100),
|
||||
rating: ChatFeedbackRatingEnum,
|
||||
reason: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
export const N8NChatJobResponseSchema = z.object({
|
||||
requestId: z.string().min(1).optional(),
|
||||
jobId: z.string().min(1),
|
||||
status: ChatJobStatusEnum,
|
||||
progress: z
|
||||
.object({
|
||||
stage: z.string().max(100).optional(),
|
||||
message: z.string().max(500).optional(),
|
||||
})
|
||||
.optional(),
|
||||
message: ChatAssistantMessageSchema.optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.string().max(100).optional(),
|
||||
message: z.string().max(500),
|
||||
})
|
||||
.optional(),
|
||||
meta: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
// ================================
|
||||
@@ -367,36 +139,13 @@ export const N8NChatJobResponseSchema = z.object({
|
||||
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
||||
export type Tag = z.infer<typeof TagSchema>;
|
||||
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
||||
export type ProjectIngestionInput = z.infer<typeof ProjectIngestionInputSchema>;
|
||||
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>;
|
||||
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
|
||||
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
|
||||
export type JsonObject = z.infer<typeof JsonObjectSchema>;
|
||||
export type ChatMode = z.infer<typeof ChatModeEnum>;
|
||||
export type ChatRole = z.infer<typeof ChatRoleEnum>;
|
||||
export type ChatBlockType = z.infer<typeof ChatBlockTypeEnum>;
|
||||
export type ChatEventType = z.infer<typeof ChatEventTypeEnum>;
|
||||
export type SignalSource = z.infer<typeof SignalSourceEnum>;
|
||||
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
|
||||
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
|
||||
export type SignalIngestionInput = z.infer<typeof SignalIngestionInputSchema>;
|
||||
export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;
|
||||
export type SignalQuery = z.infer<typeof SignalQuerySchema>;
|
||||
export type ChatJobStatus = z.infer<typeof ChatJobStatusEnum>;
|
||||
export type ChatFeedbackRating = z.infer<typeof ChatFeedbackRatingEnum>;
|
||||
export type ChatCitation = z.infer<typeof ChatCitationSchema>;
|
||||
export type ChatMessageBlock = z.infer<typeof ChatMessageBlockSchema>;
|
||||
export type ChatAssistantMessage = z.infer<typeof ChatAssistantMessageSchema>;
|
||||
export type ChatMessageRequest = z.infer<typeof ChatMessageRequestSchema>;
|
||||
export type ChatSessionQuery = z.infer<typeof ChatSessionQuerySchema>;
|
||||
export type ChatJobStatusQuery = z.infer<typeof ChatJobStatusQuerySchema>;
|
||||
export type ChatFeedbackRequest = z.infer<typeof ChatFeedbackRequestSchema>;
|
||||
export type N8NChatJobResponse = z.infer<typeof N8NChatJobResponseSchema>;
|
||||
|
||||
// ================================
|
||||
// Tags API Schemas
|
||||
@@ -420,67 +169,71 @@ const NewMergeTargetSchema = z.object({
|
||||
|
||||
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
|
||||
|
||||
export const TagMergeSchema = z.object({
|
||||
target: MergeTargetSchema,
|
||||
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenSourceIds = new Set<string>();
|
||||
export const TagMergeSchema = z
|
||||
.object({
|
||||
target: MergeTargetSchema,
|
||||
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const seenSourceIds = new Set<string>();
|
||||
|
||||
data.sourceTagIds.forEach((sourceTagId, index) => {
|
||||
if (seenSourceIds.has(sourceTagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sourceTagIds", index],
|
||||
message: `Duplicate source tag ID: ${sourceTagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenSourceIds.add(sourceTagId);
|
||||
});
|
||||
|
||||
if ("id" in data.target && seenSourceIds.has(data.target.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sourceTagIds"],
|
||||
message: "Self merge is not allowed: target tag cannot be in sourceTagIds",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const TagMaintenanceRequestSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
updates: z.array(TagUpdateSchema).default([]),
|
||||
merges: z.array(TagMergeSchema).default([]),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenUpdateTagIds = new Set<string>();
|
||||
const seenMergeSourceTagIds = new Set<string>();
|
||||
|
||||
data.updates.forEach((update, index) => {
|
||||
if (seenUpdateTagIds.has(update.tagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["updates", index, "tagId"],
|
||||
message: `Duplicate update tag ID: ${update.tagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenUpdateTagIds.add(update.tagId);
|
||||
});
|
||||
|
||||
data.merges.forEach((merge, mergeIndex) => {
|
||||
merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => {
|
||||
if (seenMergeSourceTagIds.has(sourceTagId)) {
|
||||
data.sourceTagIds.forEach((sourceTagId, index) => {
|
||||
if (seenSourceIds.has(sourceTagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["merges", mergeIndex, "sourceTagIds", sourceIndex],
|
||||
message: `Source tag ID appears in multiple merges: ${sourceTagId}`,
|
||||
path: ["sourceTagIds", index],
|
||||
message: `Duplicate source tag ID: ${sourceTagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenMergeSourceTagIds.add(sourceTagId);
|
||||
seenSourceIds.add(sourceTagId);
|
||||
});
|
||||
|
||||
if ("id" in data.target && seenSourceIds.has(data.target.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sourceTagIds"],
|
||||
message: "Self merge is not allowed: target tag cannot be in sourceTagIds",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const TagMaintenanceRequestSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
updates: z.array(TagUpdateSchema).default([]),
|
||||
merges: z.array(TagMergeSchema).default([]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const seenUpdateTagIds = new Set<string>();
|
||||
const seenMergeSourceTagIds = new Set<string>();
|
||||
|
||||
data.updates.forEach((update, index) => {
|
||||
if (seenUpdateTagIds.has(update.tagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["updates", index, "tagId"],
|
||||
message: `Duplicate update tag ID: ${update.tagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenUpdateTagIds.add(update.tagId);
|
||||
});
|
||||
|
||||
data.merges.forEach((merge, mergeIndex) => {
|
||||
merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => {
|
||||
if (seenMergeSourceTagIds.has(sourceTagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["merges", mergeIndex, "sourceTagIds", sourceIndex],
|
||||
message: `Source tag ID appears in multiple merges: ${sourceTagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenMergeSourceTagIds.add(sourceTagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Project Tag Reset API Schemas
|
||||
@@ -507,62 +260,61 @@ export const ProjectTagResetItemSchema = z.object({
|
||||
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
|
||||
});
|
||||
|
||||
export const ProjectTagResetRequestSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
dryRun: z.boolean().default(false),
|
||||
replaceAllCategories: z.boolean().default(true),
|
||||
categories: z.array(ResettableTagCategorySchema).min(1).default([
|
||||
"FIXED_PROJECT_TYPE",
|
||||
"TECH_STACK",
|
||||
"AI_PARADIGM",
|
||||
"PRODUCT_FORM",
|
||||
"DOMAIN_SCENARIO",
|
||||
]),
|
||||
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenProjectSlugs = new Set<string>();
|
||||
export const ProjectTagResetRequestSchema = z
|
||||
.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
dryRun: z.boolean().default(false),
|
||||
replaceAllCategories: z.boolean().default(true),
|
||||
categories: z
|
||||
.array(ResettableTagCategorySchema)
|
||||
.min(1)
|
||||
.default([
|
||||
"FIXED_PROJECT_TYPE",
|
||||
"TECH_STACK",
|
||||
"AI_PARADIGM",
|
||||
"PRODUCT_FORM",
|
||||
"DOMAIN_SCENARIO",
|
||||
]),
|
||||
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const seenProjectSlugs = new Set<string>();
|
||||
|
||||
data.projects.forEach((project, projectIndex) => {
|
||||
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
||||
if (seenProjectSlugs.has(normalizedSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["projects", projectIndex, "projectSlug"],
|
||||
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenProjectSlugs.add(normalizedSlug);
|
||||
data.projects.forEach((project, projectIndex) => {
|
||||
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
||||
if (seenProjectSlugs.has(normalizedSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["projects", projectIndex, "projectSlug"],
|
||||
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenProjectSlugs.add(normalizedSlug);
|
||||
|
||||
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
||||
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
||||
>;
|
||||
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
||||
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
||||
>;
|
||||
|
||||
categories.forEach((category) => {
|
||||
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
||||
const seenTagSlugs = new Set<string>();
|
||||
categories.forEach((category) => {
|
||||
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
||||
const seenTagSlugs = new Set<string>();
|
||||
|
||||
selectedSlugs.forEach((slug, slugIndex) => {
|
||||
const normalizedTagSlug = slug.trim().toLowerCase();
|
||||
if (seenTagSlugs.has(normalizedTagSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [
|
||||
"projects",
|
||||
projectIndex,
|
||||
"selectedTagSlugsByCategory",
|
||||
category,
|
||||
slugIndex,
|
||||
],
|
||||
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenTagSlugs.add(normalizedTagSlug);
|
||||
selectedSlugs.forEach((slug, slugIndex) => {
|
||||
const normalizedTagSlug = slug.trim().toLowerCase();
|
||||
if (seenTagSlugs.has(normalizedTagSlug)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["projects", projectIndex, "selectedTagSlugsByCategory", category, slugIndex],
|
||||
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenTagSlugs.add(normalizedTagSlug);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
// Types
|
||||
|
||||
Reference in New Issue
Block a user