From fc46804405b38527a2647c3972b683d5db1b6f82 Mon Sep 17 00:00:00 2001 From: mzaxd Date: Sun, 1 Feb 2026 22:51:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Tag=20Janitor=20AP?= =?UTF-8?q?I=20=E7=94=A8=E4=BA=8E=20n8n=20=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 GET /api/tags 接口返回标签列表及项目计数 - 新增 POST /api/tags/maintenance 接口支持批量 nameEn 补全和标签合并 - 合并逻辑包含 ProjectTag 去重处理(避免复合主键冲突) - 成功后触发 ISR revalidatePath 刷新项目列表页 - 新增 Zod 校验 schemas(TagMaintenanceRequestSchema 等) - 包含 n8n workflow JSON 模板及文档 --- docs/n8n-tag-janitor.md | 145 +++++++++++++ docs/n8n/tag-janitor-workflow.json | 151 ++++++++++++++ src/app/api/tags/maintenance/route.test.ts | 148 ++++++++++++++ src/app/api/tags/maintenance/route.ts | 166 +++++++++++++++ src/app/api/tags/route.test.ts | 27 +++ src/app/api/tags/route.ts | 32 +++ src/lib/validations.ts | 224 +++++++++++++-------- 7 files changed, 812 insertions(+), 81 deletions(-) create mode 100644 docs/n8n-tag-janitor.md create mode 100644 docs/n8n/tag-janitor-workflow.json create mode 100644 src/app/api/tags/maintenance/route.test.ts create mode 100644 src/app/api/tags/maintenance/route.ts create mode 100644 src/app/api/tags/route.test.ts create mode 100644 src/app/api/tags/route.ts diff --git a/docs/n8n-tag-janitor.md b/docs/n8n-tag-janitor.md new file mode 100644 index 0000000..da4d1d8 --- /dev/null +++ b/docs/n8n-tag-janitor.md @@ -0,0 +1,145 @@ +# n8n Tag Janitor Workflow + +## Overview + +Daily automated workflow to clean up duplicate/similar tags using AI semantic analysis. + +## Workflow Structure + +[Cron] → [HTTP GET /api/tags] → [Code: Preprocess] → [AI: Generate Plan] → [Code: Validate JSON] → [HTTP POST /api/tags/maintenance] → [Notification] + +## Node Configuration + +### 1. Schedule Trigger (Cron) + +- Trigger: Daily at 03:00 UTC +- Timezone: UTC + +### 2. HTTP Request - Fetch Tags + +- Method: GET +- URL: `{{$env.SITE_BASE_URL}}/api/tags` +- Response: JSON with `.tags` array + +### 3. Code Node - Preprocess + +Purpose: Format tags for LLM input, handle chunking if >200 tags + +```javascript +const tags = $input.first().json.tags; +const formatted = tags.map((t) => ({ + id: t.id, + name: t.name, + nameEn: t.nameEn || "", + projectCount: t._count.projects, +})); +// Sort by projectCount desc for prioritization +formatted.sort((a, b) => b.projectCount - a.projectCount); +return [{ json: { tags: formatted, total: formatted.length } }]; +``` + +### 4. AI Node - Generate Merge Plan + +Model: GPT-4o / Claude 3.5 Sonnet +Temperature: 0.1 (deterministic) + +Prompt Template: + +``` +You are a tag management expert. Analyze these tags and identify: +1. Semantic duplicates that should be merged (e.g., "机器学习" and "ML" → keep "机器学习" with nameEn "Machine Learning") +2. Tags missing English names that need nameEn补全 + +Tags (JSON): +{{$json.tags}} + +Output STRICT JSON (no markdown, no explanation): +{ + "merges": [ + { + "target": { "name": "保留的标签名", "nameEn": "Canonical English Name" }, + "sourceTagIds": ["id1", "id2"] + } + ], + "updates": [ + { "tagId": "id", "nameEn": "English Name" } + ] +} + +Rules: +- Keep the tag with higher projectCount as target +- For merges, target can be { "id": "existing_id" } if keeping existing tag, or { "name": "...", "nameEn": "..." } to create new +- Only include tags that NEED action (empty arrays if nothing to do) +- nameEn should be proper English, not pinyin +- Common tech terms: 机器学习=Machine Learning, 深度学习=Deep Learning, 自然语言处理=NLP +``` + +### 5. Code Node - Validate & Parse + +```javascript +const response = $input.first().json; +let plan; +try { + plan = typeof response === "string" ? JSON.parse(response) : response; +} catch (e) { + throw new Error("Invalid JSON from AI: " + e.message); +} + +// Validate structure +if (!Array.isArray(plan.merges)) plan.merges = []; +if (!Array.isArray(plan.updates)) plan.updates = []; + +// Self-merge check: target.id cannot be in sourceTagIds +for (const merge of plan.merges) { + if (merge.target.id && merge.sourceTagIds.includes(merge.target.id)) { + throw new Error("Self-merge detected: " + merge.target.id); + } +} + +return [{ json: plan }]; +``` + +### 6. HTTP Request - Execute Maintenance + +- Method: POST +- URL: `{{$env.SITE_BASE_URL}}/api/tags/maintenance` +- Body: + +```json +{ + "apiKey": "{{$env.WEBHOOK_API_KEY}}", + "updates": {{$json.updates}}, + "merges": {{$json.merges}} +} +``` + +### 7. Notification (Slack/Email/Webhook) + +Send summary: + +- Tags merged: X +- Tags deleted: Y +- Names updated: Z +- Errors: [list] + +## Environment Variables Required + +- `SITE_BASE_URL`: https://your-site.com +- `WEBHOOK_API_KEY`: API key for authentication + +## Chunking Strategy (for >200 tags) + +1. Split tags into batches of 100 +2. Process each batch sequentially +3. Aggregate results before final notification + +## Error Handling + +- Retry HTTP requests 3x with exponential backoff +- On AI parse failure: skip and alert +- On maintenance failure: log error, continue with notification + +## Testing + +1. Dry run: Comment out HTTP POST node, check AI output only +2. Real run: Enable all nodes, monitor /api/tags count before/after diff --git a/docs/n8n/tag-janitor-workflow.json b/docs/n8n/tag-janitor-workflow.json new file mode 100644 index 0000000..b58e088 --- /dev/null +++ b/docs/n8n/tag-janitor-workflow.json @@ -0,0 +1,151 @@ +{ + "name": "Tag Janitor - Daily Cleanup", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "triggerAtHour": 3 + } + ] + } + }, + "id": "schedule", + "name": "Daily 3AM UTC", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [0, 0] + }, + { + "parameters": { + "url": "={{$env.SITE_BASE_URL}}/api/tags", + "options": {} + }, + "id": "fetch-tags", + "name": "Fetch Tags", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [220, 0] + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nif (!response.success) {\n throw new Error('Failed to fetch tags: ' + JSON.stringify(response));\n}\n\nconst tags = response.tags;\nconst formatted = tags.map(t => ({\n id: t.id,\n name: t.name,\n nameEn: t.nameEn || '',\n projectCount: t._count?.projects || 0\n}));\n\n// Sort by project count descending\nformatted.sort((a, b) => b.projectCount - a.projectCount);\n\nreturn [{ json: { tags: formatted, total: formatted.length } }];" + }, + "id": "preprocess", + "name": "Preprocess Tags", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [440, 0] + }, + { + "parameters": { + "model": "gpt-4o", + "messages": { + "values": [ + { + "role": "system", + "content": "You are a tag management expert. Analyze tags and identify semantic duplicates for merging and missing English names for completion. Output STRICT JSON only." + }, + { + "role": "user", + "content": "Analyze these tags and identify:\n1. Semantic duplicates to merge (e.g., \"机器学习\" and \"ML\" → keep higher projectCount)\n2. Tags missing nameEn that need English names\n\nTags ({{$json.total}} total):\n{{JSON.stringify($json.tags)}}\n\nOutput STRICT JSON (no markdown):\n{\n \"merges\": [\n {\n \"target\": { \"name\": \"保留的标签\", \"nameEn\": \"Canonical Name\" },\n \"sourceTagIds\": [\"id1\", \"id2\"]\n }\n ],\n \"updates\": [\n { \"tagId\": \"id\", \"nameEn\": \"English Name\" }\n ]\n}\n\nRules:\n- Keep tag with higher projectCount as merge target\n- target can use { \"id\": \"existing\" } to keep existing tag\n- Only include tags needing action (empty arrays if none)\n- nameEn must be proper English, not pinyin" + } + ] + }, + "options": { + "temperature": 0.1 + } + }, + "id": "ai-analyze", + "name": "AI Analyze Tags", + "type": "@n8n/n8n-nodes-langchain.openAi", + "typeVersion": 1.8, + "position": [660, 0] + }, + { + "parameters": { + "jsCode": "const response = $input.first().json;\nlet plan;\n\ntry {\n const content = response.message?.content || response.text || response;\n plan = typeof content === 'string' ? JSON.parse(content) : content;\n} catch (e) {\n throw new Error('Failed to parse AI response: ' + e.message);\n}\n\nif (!Array.isArray(plan.merges)) plan.merges = [];\nif (!Array.isArray(plan.updates)) plan.updates = [];\n\nfor (const merge of plan.merges) {\n if (merge.target.id && merge.sourceTagIds.includes(merge.target.id)) {\n throw new Error('Self-merge detected: ' + merge.target.id);\n }\n}\n\nif (plan.merges.length === 0 && plan.updates.length === 0) {\n return [{ json: { skipped: true, reason: 'No changes needed' } }];\n}\n\nreturn [{ json: plan }];" + }, + "id": "validate-json", + "name": "Validate JSON", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [880, 0] + }, + { + "parameters": { + "conditions": { + "boolean": [ + { + "value1": "={{$json.skipped}}", + "value2": true + } + ] + } + }, + "id": "check-skip", + "name": "Check Skip", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [1100, 0] + }, + { + "parameters": { + "method": "POST", + "url": "={{$env.SITE_BASE_URL}}/api/tags/maintenance", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={\n \"apiKey\": \"{{$env.WEBHOOK_API_KEY}}\",\n \"updates\": {{JSON.stringify($json.updates)}},\n \"merges\": {{JSON.stringify($json.merges)}}\n}", + "options": {} + }, + "id": "execute-maintenance", + "name": "Execute Maintenance", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [1320, -100] + }, + { + "parameters": { + "jsCode": "const maintenanceResult = $('Execute Maintenance').first()?.json || {};\nconst skipped = $('Check Skip').first()?.json?.skipped;\n\nif (skipped) {\n return [{ json: {\n status: 'skipped',\n message: 'No changes needed',\n timestamp: new Date().toISOString()\n }}];\n}\n\nconst result = maintenanceResult.result || {};\n\nreturn [{ json: {\n status: maintenanceResult.success ? 'success' : 'failed',\n updatedCount: result.updatedCount || 0,\n mergedCount: result.mergedCount || 0,\n deletedTagCount: result.deletedTagCount || 0,\n timestamp: new Date().toISOString(),\n error: maintenanceResult.error || null\n}}];" + }, + "id": "summarize", + "name": "Summarize Results", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [1540, 0] + } + ], + "connections": { + "Daily 3AM UTC": { + "main": [[{ "node": "Fetch Tags", "type": "main", "index": 0 }]] + }, + "Fetch Tags": { + "main": [[{ "node": "Preprocess Tags", "type": "main", "index": 0 }]] + }, + "Preprocess Tags": { + "main": [[{ "node": "AI Analyze Tags", "type": "main", "index": 0 }]] + }, + "AI Analyze Tags": { + "main": [[{ "node": "Validate JSON", "type": "main", "index": 0 }]] + }, + "Validate JSON": { + "main": [[{ "node": "Check Skip", "type": "main", "index": 0 }]] + }, + "Check Skip": { + "main": [ + [{ "node": "Execute Maintenance", "type": "main", "index": 0 }], + [{ "node": "Summarize Results", "type": "main", "index": 0 }] + ] + }, + "Execute Maintenance": { + "main": [[{ "node": "Summarize Results", "type": "main", "index": 0 }]] + } + }, + "settings": { + "executionOrder": "v1" + }, + "meta": { + "templateCredsSetupCompleted": true + } +} diff --git a/src/app/api/tags/maintenance/route.test.ts b/src/app/api/tags/maintenance/route.test.ts new file mode 100644 index 0000000..f75327d --- /dev/null +++ b/src/app/api/tags/maintenance/route.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { POST } from "./route"; +import { NextRequest } from "next/server"; + +describe("POST /api/tags/maintenance", () => { + beforeEach(() => { + process.env.WEBHOOK_API_KEY = "test-key-32-characters-long!!"; + }); + + it("should reject without API key", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ updates: [], merges: [] }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const json = await response.json(); + expect(json.success).toBe(false); + expect(json.error).toBe("Validation error"); + }); + + it("should reject with invalid API key format", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "short", + updates: [], + merges: [], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const json = await response.json(); + expect(json.success).toBe(false); + expect(json.error).toBe("Validation error"); + }); + + it("should reject with wrong API key", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "a".repeat(32), + updates: [], + merges: [], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(401); + + const json = await response.json(); + expect(json.success).toBe(false); + expect(json.error).toBe("Unauthorized"); + }); + + it("should validate updates array structure", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "test-key-32-characters-long!!", + updates: [{ tagId: "" }], + merges: [], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const json = await response.json(); + expect(json.success).toBe(false); + }); + + it("should validate merges array structure", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "test-key-32-characters-long!!", + updates: [], + merges: [{ target: {}, sourceTagIds: [] }], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + + const json = await response.json(); + expect(json.success).toBe(false); + }); + + it("should accept valid updates with proper structure", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "test-key-32-characters-long!!", + updates: [{ tagId: "test-id", nameEn: "Test Tag English" }], + merges: [], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json.success).toBe(true); + }); + + it("should accept valid merges with proper structure", async () => { + const request = new NextRequest("http://localhost:3000/api/tags/maintenance", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + apiKey: "test-key-32-characters-long!!", + updates: [], + merges: [{ target: { name: "Target Tag" }, sourceTagIds: ["tag1", "tag2"] }], + }), + }); + + const response = await POST(request); + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json.success).toBe(true); + }); +}); diff --git a/src/app/api/tags/maintenance/route.ts b/src/app/api/tags/maintenance/route.ts new file mode 100644 index 0000000..2503b44 --- /dev/null +++ b/src/app/api/tags/maintenance/route.ts @@ -0,0 +1,166 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { revalidatePath } from "next/cache"; +import { prisma } from "@/lib/prisma"; +import { TagMaintenanceRequestSchema } from "@/lib/validations"; +import { generateSlug } from "@/lib/slug"; + +export async function POST(request: NextRequest) { + try { + // 1. Parse body + const body = await request.json(); + + // 2. Validate with Zod + const validation = TagMaintenanceRequestSchema.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, updates, merges } = validation.data; + + // 3. Authenticate with timing-safe comparison + const expectedApiKey = process.env.WEBHOOK_API_KEY; + const providedBuf = Buffer.from(apiKey); + const expectedBuf = Buffer.from(expectedApiKey || ""); + if ( + !expectedApiKey || + providedBuf.length !== expectedBuf.length || + !crypto.timingSafeEqual(providedBuf, expectedBuf) + ) { + return NextResponse.json( + { + success: false, + error: "Unauthorized", + details: ["Invalid or missing API Key"], + }, + { status: 401 } + ); + } + + // 4. Execute in transaction + const result = await prisma.$transaction(async (tx) => { + let updatedCount = 0; + let mergedCount = 0; + let deletedTagCount = 0; + + // 4a. Execute updates (nameEn补全) + for (const update of updates) { + await tx.tag.update({ + where: { id: update.tagId }, + data: { nameEn: update.nameEn }, + }); + updatedCount++; + } + + // 4b. Execute merges + for (const merge of merges) { + // Resolve or create target tag + let targetTagId: string; + + if ("id" in merge.target) { + // Use existing tag + targetTagId = merge.target.id; + } else { + // Create or find by name + const slug = generateSlug(merge.target.name, merge.target.nameEn); + const existing = await tx.tag.findFirst({ + where: { + OR: [{ name: merge.target.name }, { slug }], + }, + select: { id: true }, + }); + + if (existing) { + // Update existing tag's nameEn + await tx.tag.update({ + where: { id: existing.id }, + data: { nameEn: merge.target.nameEn }, + }); + targetTagId = existing.id; + } else { + // Create new tag + const newTag = await tx.tag.create({ + data: { + name: merge.target.name, + nameEn: merge.target.nameEn, + slug, + }, + select: { id: true }, + }); + targetTagId = newTag.id; + } + } + + const sourceTagIds = merge.sourceTagIds.filter((id) => id !== targetTagId); + if (sourceTagIds.length === 0) { + mergedCount++; + continue; + } + + // Get all projectIds from source tags + const sourceProjectTags = await tx.projectTag.findMany({ + where: { tagId: { in: sourceTagIds } }, + select: { projectId: true }, + }); + + // Get existing projectIds for target tag (to avoid duplicates) + const existingTargetProjectTags = await tx.projectTag.findMany({ + where: { tagId: targetTagId }, + select: { projectId: true }, + }); + const existingProjectIds = new Set(existingTargetProjectTags.map((pt) => pt.projectId)); + + // Filter to only new projectIds (deduplication) + const newProjectIds = [...new Set(sourceProjectTags.map((pt) => pt.projectId))].filter( + (pid) => !existingProjectIds.has(pid) + ); + + // Create new ProjectTag entries for target + if (newProjectIds.length > 0) { + await tx.projectTag.createMany({ + data: newProjectIds.map((projectId) => ({ + projectId, + tagId: targetTagId, + })), + }); + } + + // Delete source tags (cascade deletes their ProjectTags) + const deleteResult = await tx.tag.deleteMany({ + where: { id: { in: sourceTagIds } }, + }); + + mergedCount++; + deletedTagCount += deleteResult.count; + } + + return { updatedCount, mergedCount, deletedTagCount }; + }); + + // 5. Revalidate ISR paths + revalidatePath("/zh/projects", "page"); + revalidatePath("/en/projects", "page"); + + return NextResponse.json({ + success: true, + result, + }); + } catch (error) { + console.error("[POST /api/tags/maintenance] Error:", error); + return NextResponse.json( + { + success: false, + error: "Internal server error", + details: [error instanceof Error ? error.message : "Unknown error"], + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/tags/route.test.ts b/src/app/api/tags/route.test.ts new file mode 100644 index 0000000..bfb56e1 --- /dev/null +++ b/src/app/api/tags/route.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from "vitest"; +import { GET } from "./route"; + +describe("GET /api/tags", () => { + it("should return success with tags array", async () => { + const response = await GET(); + + expect(response.status).toBe(200); + + const json = await response.json(); + expect(json.success).toBe(true); + expect(json).toHaveProperty("tags"); + expect(Array.isArray(json.tags)).toBe(true); + }); + + it("should include _count.projects in tags", async () => { + const response = await GET(); + + expect(response.status).toBe(200); + + const json = await response.json(); + if (json.tags.length > 0) { + expect(json.tags[0]).toHaveProperty("_count"); + expect(json.tags[0]._count).toHaveProperty("projects"); + } + }); +}); diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts new file mode 100644 index 0000000..a7ea3d1 --- /dev/null +++ b/src/app/api/tags/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; + +export async function GET() { + try { + const tags = await prisma.tag.findMany({ + include: { + _count: { + select: { projects: true }, + }, + }, + orderBy: { + name: "asc", + }, + }); + + return NextResponse.json({ + success: true, + tags, + }); + } catch (error) { + console.error("[GET /api/tags] Error:", error); + return NextResponse.json( + { + success: false, + error: "Internal server error", + details: [error instanceof Error ? error.message : "Unknown error"], + }, + { status: 500 } + ); + } +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index b8f9a90..c748b95 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -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 -export type Tag = z.infer -export type ProjectInput = z.infer -export type WebhookPayload = z.infer -export type ProjectQuery = z.infer -export type TaskStatus = z.infer -export type CreateDiscoveryTask = z.infer -export type UpdateDiscoveryTask = z.infer -export type GetDiscoveryTasksQuery = z.infer -export type BatchResetTasks = z.infer +export type ExternalLink = z.infer; +export type Tag = z.infer; +export type ProjectInput = z.infer; +export type WebhookPayload = z.infer; +export type ProjectQuery = z.infer; +export type TaskStatus = z.infer; +export type CreateDiscoveryTask = z.infer; +export type UpdateDiscoveryTask = z.infer; +export type GetDiscoveryTasksQuery = z.infer; +export type BatchResetTasks = z.infer; // ================================ // Keyword Cloud Schemas @@ -147,11 +151,15 @@ export type BatchResetTasks = z.infer // 视觉配置 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 -export type BatchKeywordsRequest = z.infer -export type Quarter = z.infer -export type VisualStyleRule = z.infer -export type AIEventInput = z.infer -export type AIEventQuery = z.infer -export type KeywordCloudResponse = z.infer +export type KeywordInput = z.infer; +export type BatchKeywordsRequest = z.infer; +export type Quarter = z.infer; +export type VisualStyleRule = z.infer; +export type AIEventInput = z.infer; +export type AIEventQuery = z.infer; +export type KeywordCloudResponse = z.infer; + +// ================================ +// 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; +export type MergeTarget = z.infer; +export type TagMerge = z.infer; +export type TagMaintenanceRequest = z.infer;