357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
import { z } from "zod";
|
||
|
||
// ================================
|
||
// Enums
|
||
// ================================
|
||
|
||
export const ProjectStatusEnum = z.enum(["ACTIVE", "ARCHIVED"]);
|
||
export const LinkTypeEnum = z.enum(["WEBSITE", "GITHUB", "HUGGINGFACE", "PAPER"]);
|
||
|
||
// ================================
|
||
// Base Schemas
|
||
// ================================
|
||
|
||
export const ExternalLinkSchema = z.object({
|
||
type: LinkTypeEnum,
|
||
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);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}, "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(),
|
||
});
|
||
|
||
// ================================
|
||
// Project Schemas
|
||
// ================================
|
||
|
||
export const ProjectBaseSchema = z.object({
|
||
name: z.string().min(1).max(200),
|
||
nameEn: z.string().max(200).optional(),
|
||
description: z.string().min(10).max(500),
|
||
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(),
|
||
});
|
||
|
||
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),
|
||
});
|
||
|
||
// ================================
|
||
// Webhook Schemas
|
||
// ================================
|
||
|
||
export const WebhookAuthSchema = z.object({
|
||
apiKey: z.string().min(32, "Invalid API key format"),
|
||
});
|
||
|
||
export const WebhookPayloadSchema = WebhookAuthSchema.extend({
|
||
projects: z.array(ProjectInputSchema).min(1).max(100),
|
||
});
|
||
|
||
// ================================
|
||
// Discovery Task Schemas
|
||
// ================================
|
||
|
||
export const TaskStatusEnum = z.enum(["PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"]);
|
||
|
||
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: TaskStatusEnum.optional(),
|
||
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(),
|
||
});
|
||
|
||
// ================================
|
||
// Query Schemas
|
||
// ================================
|
||
|
||
export const ProjectQuerySchema = z.object({
|
||
search: z.string().min(2).max(100).optional(),
|
||
tags: z.array(z.string()).optional(),
|
||
domains: z.array(z.string()).optional(),
|
||
productForms: 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),
|
||
});
|
||
|
||
// ================================
|
||
// 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 AIEventInput = z.infer<typeof AIEventInputSchema>;
|
||
export type JsonObject = z.infer<typeof JsonObjectSchema>;
|
||
|
||
// ================================
|
||
// 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),
|
||
});
|
||
|
||
const ExistingMergeTargetSchema = z.object({
|
||
id: z.string().min(1, "Target tag ID is required"),
|
||
name: z.string().min(1).max(100).optional(),
|
||
nameEn: z.string().min(1).max(100).optional(),
|
||
});
|
||
|
||
const NewMergeTargetSchema = z.object({
|
||
name: z.string().min(1, "Target name is required").max(100),
|
||
nameEn: z.string().min(1, "Target English name is required").max(100),
|
||
});
|
||
|
||
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
|
||
|
||
export const TagMergeSchema = z.object({
|
||
target: MergeTargetSchema,
|
||
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
||
}).superRefine((data, ctx) => {
|
||
const seenSourceIds = new Set<string>();
|
||
|
||
data.sourceTagIds.forEach((sourceTagId, index) => {
|
||
if (seenSourceIds.has(sourceTagId)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["sourceTagIds", index],
|
||
message: `Duplicate source tag ID: ${sourceTagId}`,
|
||
});
|
||
return;
|
||
}
|
||
seenSourceIds.add(sourceTagId);
|
||
});
|
||
|
||
if ("id" in data.target && seenSourceIds.has(data.target.id)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["sourceTagIds"],
|
||
message: "Self merge is not allowed: target tag cannot be in sourceTagIds",
|
||
});
|
||
}
|
||
});
|
||
|
||
export const TagMaintenanceRequestSchema = z.object({
|
||
apiKey: z.string().min(32, "Invalid API key format"),
|
||
updates: z.array(TagUpdateSchema).default([]),
|
||
merges: z.array(TagMergeSchema).default([]),
|
||
}).superRefine((data, ctx) => {
|
||
const seenUpdateTagIds = new Set<string>();
|
||
const seenMergeSourceTagIds = new Set<string>();
|
||
|
||
data.updates.forEach((update, index) => {
|
||
if (seenUpdateTagIds.has(update.tagId)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["updates", index, "tagId"],
|
||
message: `Duplicate update tag ID: ${update.tagId}`,
|
||
});
|
||
return;
|
||
}
|
||
seenUpdateTagIds.add(update.tagId);
|
||
});
|
||
|
||
data.merges.forEach((merge, mergeIndex) => {
|
||
merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => {
|
||
if (seenMergeSourceTagIds.has(sourceTagId)) {
|
||
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
|
||
// ================================
|
||
|
||
const ResettableTagCategorySchema = z.enum([
|
||
"FIXED_PROJECT_TYPE",
|
||
"TECH_STACK",
|
||
"AI_PARADIGM",
|
||
"PRODUCT_FORM",
|
||
"DOMAIN_SCENARIO",
|
||
]);
|
||
|
||
const ProjectTagSelectionByCategorySchema = z.object({
|
||
FIXED_PROJECT_TYPE: z.array(z.string().trim().min(1)).min(1).max(1),
|
||
TECH_STACK: z.array(z.string().trim().min(1)).max(20).default([]),
|
||
AI_PARADIGM: z.array(z.string().trim().min(1)).max(20).default([]),
|
||
PRODUCT_FORM: z.array(z.string().trim().min(1)).max(20).default([]),
|
||
DOMAIN_SCENARIO: z.array(z.string().trim().min(1)).max(20).default([]),
|
||
});
|
||
|
||
export const ProjectTagResetItemSchema = z.object({
|
||
projectSlug: z.string().trim().min(1).max(200),
|
||
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
|
||
});
|
||
|
||
export const ProjectTagResetRequestSchema = z.object({
|
||
apiKey: z.string().min(32, "Invalid API key format"),
|
||
dryRun: z.boolean().default(false),
|
||
replaceAllCategories: z.boolean().default(true),
|
||
categories: z.array(ResettableTagCategorySchema).min(1).default([
|
||
"FIXED_PROJECT_TYPE",
|
||
"TECH_STACK",
|
||
"AI_PARADIGM",
|
||
"PRODUCT_FORM",
|
||
"DOMAIN_SCENARIO",
|
||
]),
|
||
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
|
||
}).superRefine((data, ctx) => {
|
||
const seenProjectSlugs = new Set<string>();
|
||
|
||
data.projects.forEach((project, projectIndex) => {
|
||
const normalizedSlug = project.projectSlug.trim().toLowerCase();
|
||
if (seenProjectSlugs.has(normalizedSlug)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["projects", projectIndex, "projectSlug"],
|
||
message: `Duplicate projectSlug: ${project.projectSlug}`,
|
||
});
|
||
return;
|
||
}
|
||
seenProjectSlugs.add(normalizedSlug);
|
||
|
||
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
|
||
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
|
||
>;
|
||
|
||
categories.forEach((category) => {
|
||
const selectedSlugs = project.selectedTagSlugsByCategory[category];
|
||
const seenTagSlugs = new Set<string>();
|
||
|
||
selectedSlugs.forEach((slug, slugIndex) => {
|
||
const normalizedTagSlug = slug.trim().toLowerCase();
|
||
if (seenTagSlugs.has(normalizedTagSlug)) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: [
|
||
"projects",
|
||
projectIndex,
|
||
"selectedTagSlugsByCategory",
|
||
category,
|
||
slugIndex,
|
||
],
|
||
message: `Duplicate tag slug "${slug}" in category ${category}`,
|
||
});
|
||
return;
|
||
}
|
||
seenTagSlugs.add(normalizedTagSlug);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
// ================================
|
||
// 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>;
|
||
export type ResettableTagCategory = z.infer<typeof ResettableTagCategorySchema>;
|
||
export type ProjectTagSelectionByCategory = z.infer<typeof ProjectTagSelectionByCategorySchema>;
|
||
export type ProjectTagResetItem = z.infer<typeof ProjectTagResetItemSchema>;
|
||
export type ProjectTagResetRequest = z.infer<typeof ProjectTagResetRequestSchema>;
|