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
|
||||||
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
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
|
# Internationalization
|
||||||
NEXT_INTL_DEFAULT_LOCALE="zh"
|
NEXT_INTL_DEFAULT_LOCALE="zh"
|
||||||
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
|
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")
|
@@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 {
|
model ProjectTag {
|
||||||
projectId String
|
projectId String
|
||||||
tagId String
|
tagId String
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@id([projectId, tagId])
|
@@id([projectId, tagId])
|
||||||
@@index([tagId])
|
@@index([tagId])
|
||||||
@@ -59,25 +36,24 @@ model ProjectTag {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Project {
|
model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
nameEn String?
|
nameEn String?
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String
|
description String
|
||||||
descriptionEn String?
|
descriptionEn String?
|
||||||
content String?
|
content String?
|
||||||
contentEn String?
|
contentEn String?
|
||||||
githubStars Int @default(0)
|
githubStars Int @default(0)
|
||||||
githubStarsUpdatedAt DateTime?
|
githubStarsUpdatedAt DateTime?
|
||||||
status ProjectStatus @default(ACTIVE)
|
status ProjectStatus @default(ACTIVE)
|
||||||
source String?
|
source String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
embedding Unsupported("vector")?
|
embedding Unsupported("vector")?
|
||||||
embeddingUpdatedAt DateTime?
|
embeddingUpdatedAt DateTime?
|
||||||
links ExternalLink[]
|
links ExternalLink[]
|
||||||
projectDiscoveryTasks ProjectDiscoveryTask[]
|
tags ProjectTag[]
|
||||||
tags ProjectTag[]
|
|
||||||
|
|
||||||
@@index([embedding], map: "idx_project_embedding_cosine")
|
@@index([embedding], map: "idx_project_embedding_cosine")
|
||||||
@@index([slug], map: "idx_project_slug")
|
@@index([slug], map: "idx_project_slug")
|
||||||
@@ -87,13 +63,13 @@ model Project {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
nameEn String?
|
nameEn String?
|
||||||
slug String @unique
|
slug String @unique
|
||||||
category TagCategory @default(FREE_TAG)
|
category TagCategory @default(FREE_TAG)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
projects ProjectTag[]
|
projects ProjectTag[]
|
||||||
|
|
||||||
@@index([category], map: "idx_tag_category")
|
@@index([category], map: "idx_tag_category")
|
||||||
@@index([slug], map: "idx_tag_slug")
|
@@index([slug], map: "idx_tag_slug")
|
||||||
@@ -141,13 +117,6 @@ enum ProjectStatus {
|
|||||||
ARCHIVED
|
ARCHIVED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskStatus {
|
|
||||||
PENDING
|
|
||||||
IN_PROGRESS
|
|
||||||
COMPLETED
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum TagCategory {
|
enum TagCategory {
|
||||||
FIXED_PROJECT_TYPE
|
FIXED_PROJECT_TYPE
|
||||||
TECH_STACK
|
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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from '@/lib/prisma'
|
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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/projects/[slug]
|
* GET /api/projects/[slug]
|
||||||
*
|
*
|
||||||
* 根据项目的 slug 获取项目详情
|
* 根据项目的 slug 获取项目详情
|
||||||
*/
|
*/
|
||||||
export async function GET(
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
request: NextRequest,
|
|
||||||
{ params }: { params: Promise<{ slug: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
try {
|
||||||
const { slug } = await params
|
const { slug } = await params;
|
||||||
|
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
@@ -118,17 +20,17 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Not Found',
|
error: "Not Found",
|
||||||
details: [`Project with slug "${slug}" not found`],
|
details: [`Project with slug "${slug}" not found`],
|
||||||
},
|
},
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform response to match frontend structure
|
// Transform response to match frontend structure
|
||||||
@@ -140,21 +42,21 @@ export async function GET(
|
|||||||
nameEn: pt.tag.nameEn,
|
nameEn: pt.tag.nameEn,
|
||||||
slug: pt.tag.slug,
|
slug: pt.tag.slug,
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: transformedProject,
|
data: transformedProject,
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[API] Error fetching project:', error)
|
console.error("[API] Error fetching project:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal server error',
|
error: "Internal server error",
|
||||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ 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 { describe, it, expect } from "vitest";
|
||||||
import { AIEventInputSchema, ProjectInputSchema } from './validations';
|
import { ProjectInputSchema } from "./validations";
|
||||||
|
|
||||||
describe('AIEventInputSchema', () => {
|
describe("ProjectInputSchema", () => {
|
||||||
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', () => {
|
|
||||||
const baseProjectInput = {
|
const baseProjectInput = {
|
||||||
name: 'Agent Park',
|
name: "Agent Park",
|
||||||
description: 'A curated list of practical AI agent tools.',
|
description: "A curated list of practical AI agent tools.",
|
||||||
tags: [{ name: 'ai-agent' }],
|
tags: [{ name: "ai-agent" }],
|
||||||
links: [{ type: 'GITHUB' as const, url: 'https://github.com/example/repo' }],
|
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) => ({
|
const manyTags = Array.from({ length: 20 }, (_, index) => ({
|
||||||
name: `tag-${index + 1}`,
|
name: `tag-${index + 1}`,
|
||||||
}));
|
}));
|
||||||
@@ -84,7 +22,7 @@ describe('ProjectInputSchema', () => {
|
|||||||
).not.toThrow();
|
).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should still require at least one tag', () => {
|
it("should still require at least one tag", () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
ProjectInputSchema.parse({
|
ProjectInputSchema.parse({
|
||||||
...baseProjectInput,
|
...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),
|
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({
|
export const WebhookAuthSchema = z.object({
|
||||||
apiKey: z.string().min(32, "Invalid API key format"),
|
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
|
// Signals Schemas
|
||||||
// ================================
|
// ================================
|
||||||
@@ -168,13 +74,7 @@ export const SignalSourceEnum = z.enum([
|
|||||||
"product_hunt",
|
"product_hunt",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const SignalSectionStyleEnum = z.enum([
|
export const SignalSectionStyleEnum = z.enum(["focus", "debate", "evidence", "action", "risk"]);
|
||||||
"focus",
|
|
||||||
"debate",
|
|
||||||
"evidence",
|
|
||||||
"action",
|
|
||||||
"risk",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const SignalSectionSchema = z.object({
|
export const SignalSectionSchema = z.object({
|
||||||
id: z.string().min(1).max(60),
|
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),
|
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
|
// Types
|
||||||
// ================================
|
// ================================
|
||||||
@@ -367,36 +139,13 @@ export const N8NChatJobResponseSchema = z.object({
|
|||||||
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
|
||||||
export type Tag = z.infer<typeof TagSchema>;
|
export type Tag = z.infer<typeof TagSchema>;
|
||||||
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
|
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 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 SignalSource = z.infer<typeof SignalSourceEnum>;
|
||||||
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
|
export type SignalSectionStyle = z.infer<typeof SignalSectionStyleEnum>;
|
||||||
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
|
export type SignalSectionInput = z.infer<typeof SignalSectionSchema>;
|
||||||
export type SignalIngestionInput = z.infer<typeof SignalIngestionInputSchema>;
|
export type SignalIngestionInput = z.infer<typeof SignalIngestionInputSchema>;
|
||||||
export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;
|
export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;
|
||||||
export type SignalQuery = z.infer<typeof SignalQuerySchema>;
|
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
|
// Tags API Schemas
|
||||||
@@ -420,67 +169,71 @@ const NewMergeTargetSchema = z.object({
|
|||||||
|
|
||||||
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
|
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
|
||||||
|
|
||||||
export const TagMergeSchema = z.object({
|
export const TagMergeSchema = z
|
||||||
target: MergeTargetSchema,
|
.object({
|
||||||
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
target: MergeTargetSchema,
|
||||||
}).superRefine((data, ctx) => {
|
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
||||||
const seenSourceIds = new Set<string>();
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const seenSourceIds = new Set<string>();
|
||||||
|
|
||||||
data.sourceTagIds.forEach((sourceTagId, index) => {
|
data.sourceTagIds.forEach((sourceTagId, index) => {
|
||||||
if (seenSourceIds.has(sourceTagId)) {
|
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)) {
|
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["merges", mergeIndex, "sourceTagIds", sourceIndex],
|
path: ["sourceTagIds", index],
|
||||||
message: `Source tag ID appears in multiple merges: ${sourceTagId}`,
|
message: `Duplicate source tag ID: ${sourceTagId}`,
|
||||||
});
|
});
|
||||||
return;
|
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
|
// Project Tag Reset API Schemas
|
||||||
@@ -507,62 +260,61 @@ export const ProjectTagResetItemSchema = z.object({
|
|||||||
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
|
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProjectTagResetRequestSchema = z.object({
|
export const ProjectTagResetRequestSchema = z
|
||||||
apiKey: z.string().min(32, "Invalid API key format"),
|
.object({
|
||||||
dryRun: z.boolean().default(false),
|
apiKey: z.string().min(32, "Invalid API key format"),
|
||||||
replaceAllCategories: z.boolean().default(true),
|
dryRun: z.boolean().default(false),
|
||||||
categories: z.array(ResettableTagCategorySchema).min(1).default([
|
replaceAllCategories: z.boolean().default(true),
|
||||||
"FIXED_PROJECT_TYPE",
|
categories: z
|
||||||
"TECH_STACK",
|
.array(ResettableTagCategorySchema)
|
||||||
"AI_PARADIGM",
|
.min(1)
|
||||||
"PRODUCT_FORM",
|
.default([
|
||||||
"DOMAIN_SCENARIO",
|
"FIXED_PROJECT_TYPE",
|
||||||
]),
|
"TECH_STACK",
|
||||||
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
"AI_PARADIGM",
|
||||||
}).superRefine((data, ctx) => {
|
"PRODUCT_FORM",
|
||||||
const seenProjectSlugs = new Set<string>();
|
"DOMAIN_SCENARIO",
|
||||||
|
]),
|
||||||
|
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const seenProjectSlugs = new Set<string>();
|
||||||
|
|
||||||
data.projects.forEach((project, projectIndex) => {
|
data.projects.forEach((project, projectIndex) => {
|
||||||
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
||||||
if (seenProjectSlugs.has(normalizedSlug)) {
|
if (seenProjectSlugs.has(normalizedSlug)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["projects", projectIndex, "projectSlug"],
|
path: ["projects", projectIndex, "projectSlug"],
|
||||||
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
seenProjectSlugs.add(normalizedSlug);
|
seenProjectSlugs.add(normalizedSlug);
|
||||||
|
|
||||||
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
||||||
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
categories.forEach((category) => {
|
categories.forEach((category) => {
|
||||||
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
||||||
const seenTagSlugs = new Set<string>();
|
const seenTagSlugs = new Set<string>();
|
||||||
|
|
||||||
selectedSlugs.forEach((slug, slugIndex) => {
|
selectedSlugs.forEach((slug, slugIndex) => {
|
||||||
const normalizedTagSlug = slug.trim().toLowerCase();
|
const normalizedTagSlug = slug.trim().toLowerCase();
|
||||||
if (seenTagSlugs.has(normalizedTagSlug)) {
|
if (seenTagSlugs.has(normalizedTagSlug)) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: [
|
path: ["projects", projectIndex, "selectedTagSlugsByCategory", category, slugIndex],
|
||||||
"projects",
|
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
||||||
projectIndex,
|
});
|
||||||
"selectedTagSlugsByCategory",
|
return;
|
||||||
category,
|
}
|
||||||
slugIndex,
|
seenTagSlugs.add(normalizedTagSlug);
|
||||||
],
|
});
|
||||||
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seenTagSlugs.add(normalizedTagSlug);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Types
|
// Types
|
||||||
|
|||||||
Reference in New Issue
Block a user