feat: 新增 Tag Janitor API 用于 n8n 自动化标签合并

- 新增 GET /api/tags 接口返回标签列表及项目计数
- 新增 POST /api/tags/maintenance 接口支持批量 nameEn 补全和标签合并
- 合并逻辑包含 ProjectTag 去重处理(避免复合主键冲突)
- 成功后触发 ISR revalidatePath 刷新项目列表页
- 新增 Zod 校验 schemas(TagMaintenanceRequestSchema 等)
- 包含 n8n workflow JSON 模板及文档
This commit is contained in:
2026-02-01 22:51:45 +08:00
parent 3477f06fa2
commit fc46804405
7 changed files with 812 additions and 81 deletions
+143 -81
View File
@@ -1,11 +1,11 @@
import { z } from 'zod'
import { z } from "zod";
// ================================
// Enums
// ================================
export const ProjectStatusEnum = z.enum(['ACTIVE', 'ARCHIVED'])
export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER'])
export const ProjectStatusEnum = z.enum(["ACTIVE", "ARCHIVED"]);
export const LinkTypeEnum = z.enum(["WEBSITE", "GITHUB", "HUGGINGFACE", "PAPER"]);
// ================================
// Base Schemas
@@ -13,24 +13,25 @@ export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER']
export const ExternalLinkSchema = z.object({
type: LinkTypeEnum,
url: z.string()
.min(1, 'URL is required')
.max(2000, 'URL is too long')
url: z
.string()
.min(1, "URL is required")
.max(2000, "URL is too long")
.refine((url) => {
try {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
const parsed = new URL(url);
return ["http:", "https:"].includes(parsed.protocol);
} catch {
return false
return false;
}
}, 'URL must use http or https protocol'),
title: z.string().max(200).optional()
})
}, "URL must use http or https protocol"),
title: z.string().max(200).optional(),
});
export const TagSchema = z.object({
name: z.string().min(1).max(50),
nameEn: z.string().max(50).optional()
})
nameEn: z.string().max(50).optional(),
});
// ================================
// Project Schemas
@@ -43,39 +44,43 @@ export const ProjectBaseSchema = z.object({
descriptionEn: z.string().max(500).optional(),
content: z.string().max(10000).optional(),
contentEn: z.string().max(10000).optional(),
status: ProjectStatusEnum.default('ACTIVE'),
source: z.string().max(100).optional()
})
status: ProjectStatusEnum.default("ACTIVE"),
source: z.string().max(100).optional(),
});
export const ProjectInputSchema = ProjectBaseSchema.extend({
tags: z.array(TagSchema).min(1, 'At least one tag is required').max(10),
links: z.array(ExternalLinkSchema).min(1, 'At least one link is required').max(10)
})
tags: z.array(TagSchema).min(1, "At least one tag is required").max(10),
links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10),
});
// ================================
// Webhook Schemas
// ================================
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(ProjectInputSchema).min(1).max(100)
})
projects: z.array(ProjectInputSchema).min(1).max(100),
});
// ================================
// Discovery Task Schemas
// ================================
export const TaskStatusEnum = z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED'])
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
tasks: z.array(z.object({
sourceUrl: z.string().url().max(2000),
sourceType: z.string().max(50).default('manual'),
})).min(1)
})
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),
@@ -83,13 +88,13 @@ export const UpdateDiscoveryTaskSchema = z.object({
explorationData: z.record(z.unknown()).optional(),
explorationSummary: z.string().max(1000).optional(),
errorMessage: z.string().max(2000).optional(),
})
});
export const GetDiscoveryTasksQuerySchema = z.object({
status: TaskStatusEnum.optional(),
limit: z.coerce.number().int().positive().max(100).default(10),
offset: z.coerce.number().int().nonnegative().default(0),
})
});
/**
* 批量重置任务 Schema
@@ -100,10 +105,9 @@ export const BatchResetTasksSchema = WebhookAuthSchema.extend({
taskIds: z.array(z.string()).optional(),
// 模式2: 按状态筛选(不传则默认重置 IN_PROGRESS 和 FAILED
statuses: z.array(TaskStatusEnum).optional(),
}).refine(
(data) => data.taskIds || data.statuses,
{ message: '必须提供 taskIds 或 statuses 之一' }
)
}).refine((data) => data.taskIds || data.statuses, {
message: "必须提供 taskIds 或 statuses 之一",
});
/**
* 检查任务去重 Schema
@@ -112,7 +116,7 @@ export const BatchResetTasksSchema = WebhookAuthSchema.extend({
export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
urls: z.array(z.string().url().max(2000)).min(1).max(100),
sourceType: z.string().max(50).optional(),
})
});
// ================================
// Query Schemas
@@ -123,23 +127,23 @@ export const ProjectQuerySchema = z.object({
tags: z.array(z.string()).optional(),
status: ProjectStatusEnum.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20)
})
limit: z.coerce.number().int().positive().max(100).default(20),
});
// ================================
// Types
// ================================
export type ExternalLink = z.infer<typeof ExternalLinkSchema>
export type Tag = z.infer<typeof TagSchema>
export type ProjectInput = z.infer<typeof ProjectInputSchema>
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>
export type TaskStatus = z.infer<typeof TaskStatusEnum>
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>
export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
export type Tag = z.infer<typeof TagSchema>;
export type ProjectInput = z.infer<typeof ProjectInputSchema>;
export type WebhookPayload = z.infer<typeof WebhookPayloadSchema>;
export type ProjectQuery = z.infer<typeof ProjectQuerySchema>;
export type TaskStatus = z.infer<typeof TaskStatusEnum>;
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
// ================================
// Keyword Cloud Schemas
@@ -147,11 +151,15 @@ export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>
// 视觉配置 Schema
const VisualConfigSchema = z.object({
color: z.enum(['primary', 'secondary', 'accent', 'gray']),
size: z.enum(['text-5xl', 'text-4xl', 'text-3xl', 'text-2xl', 'text-xl', 'text-lg', 'text-base']),
border: z.enum(['border-4', 'border-2']),
rotation: z.string().regex(/^-?rotate-\d+$/).nullable().optional(),
})
color: z.enum(["primary", "secondary", "accent", "gray"]),
size: z.enum(["text-5xl", "text-4xl", "text-3xl", "text-2xl", "text-xl", "text-lg", "text-base"]),
border: z.enum(["border-4", "border-2"]),
rotation: z
.string()
.regex(/^-?rotate-\d+$/)
.nullable()
.optional(),
});
// 关键词输入 Schema
export const KeywordInputSchema = z.object({
@@ -162,13 +170,13 @@ export const KeywordInputSchema = z.object({
detailPoints: z.array(z.string().min(5).max(100)).min(1).max(5),
detailPointsEn: z.array(z.string().max(100)).max(5).optional(),
visualConfig: VisualConfigSchema,
})
});
// 批量写入关键词请求 Schema
export const BatchKeywordsRequestSchema = z.object({
quarter: z.string().regex(/^\d{4}-Q[1-4]$/, "格式应为 YYYY-QN"),
keywords: z.array(KeywordInputSchema).min(1).max(50),
})
});
// 季度 Schema
export const QuarterSchema = z.object({
@@ -179,29 +187,43 @@ export const QuarterSchema = z.object({
subtitleEn: z.string().max(500).optional(),
displayOrder: z.number().int().min(0).default(0),
isActive: z.boolean().default(true),
})
});
// 视觉规则 Schema
export const VisualStyleRuleSchema = z.object({
name: z.string().min(1).max(100),
minScore: z.number().int().min(0).max(100),
maxScore: z.number().int().min(0).max(100),
color: z.enum(['primary', 'secondary', 'accent', 'gray']),
size: z.enum(['text-5xl', 'text-4xl', 'text-3xl', 'text-2xl', 'text-xl', 'text-lg', 'text-base']),
border: z.enum(['border-4', 'border-2']),
rotation: z.string().regex(/^-?rotate-\d+$/).nullable().optional(),
priority: z.number().int().min(0).default(0),
enabled: z.boolean().default(true),
}).refine(data => data.minScore < data.maxScore, {
message: "minScore 必须小于 maxScore",
})
export const VisualStyleRuleSchema = z
.object({
name: z.string().min(1).max(100),
minScore: z.number().int().min(0).max(100),
maxScore: z.number().int().min(0).max(100),
color: z.enum(["primary", "secondary", "accent", "gray"]),
size: z.enum([
"text-5xl",
"text-4xl",
"text-3xl",
"text-2xl",
"text-xl",
"text-lg",
"text-base",
]),
border: z.enum(["border-4", "border-2"]),
rotation: z
.string()
.regex(/^-?rotate-\d+$/)
.nullable()
.optional(),
priority: z.number().int().min(0).default(0),
enabled: z.boolean().default(true),
})
.refine((data) => data.minScore < data.maxScore, {
message: "minScore 必须小于 maxScore",
});
// API 响应 Schema
export const KeywordCloudResponseSchema = z.object({
success: z.boolean(),
data: z.any().optional(),
error: z.string().optional(),
})
});
// ================================
// AI Timeline Schemas
@@ -215,22 +237,62 @@ export const AIEventInputSchema = z.object({
descriptionEn: z.string().max(500).optional(),
imageUrl: z.string().url(),
sourceUrl: z.string().url().optional(),
})
});
export const AIEventQuerySchema = z.object({
year: z.string().regex(/^\d{4}$/).optional(),
year: z
.string()
.regex(/^\d{4}$/)
.optional(),
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
})
});
// ================================
// Types
// ================================
export type KeywordInput = z.infer<typeof KeywordInputSchema>
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>
export type Quarter = z.infer<typeof QuarterSchema>
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>
export type AIEventInput = z.infer<typeof AIEventInputSchema>
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>
export type KeywordCloudResponse = z.infer<typeof KeywordCloudResponseSchema>
export type KeywordInput = z.infer<typeof KeywordInputSchema>;
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>;
export type Quarter = z.infer<typeof QuarterSchema>;
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>;
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>;
export type KeywordCloudResponse = z.infer<typeof KeywordCloudResponseSchema>;
// ================================
// Tags API Schemas
// ================================
export const TagUpdateSchema = z.object({
tagId: z.string().min(1, "Tag ID is required"),
nameEn: z.string().min(1, "English name is required").max(100),
});
export const MergeTargetSchema = z.union([
z.object({ id: z.string().min(1) }),
z.object({
name: z.string().min(1).max(100),
nameEn: z.string().min(1).max(100),
}),
]);
export const TagMergeSchema = z.object({
target: MergeTargetSchema,
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
});
export const TagMaintenanceRequestSchema = z.object({
apiKey: z.string().min(32, "Invalid API key format"),
updates: z.array(TagUpdateSchema).default([]),
merges: z.array(TagMergeSchema).default([]),
});
// ================================
// Types
// ================================
export type TagUpdate = z.infer<typeof TagUpdateSchema>;
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
export type TagMerge = z.infer<typeof TagMergeSchema>;
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;