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:
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+143
-81
@@ -1,11 +1,11 @@
|
|||||||
import { z } from 'zod'
|
import { z } from "zod";
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Enums
|
// Enums
|
||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
export const ProjectStatusEnum = z.enum(['ACTIVE', 'ARCHIVED'])
|
export const ProjectStatusEnum = z.enum(["ACTIVE", "ARCHIVED"]);
|
||||||
export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER'])
|
export const LinkTypeEnum = z.enum(["WEBSITE", "GITHUB", "HUGGINGFACE", "PAPER"]);
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Base Schemas
|
// Base Schemas
|
||||||
@@ -13,24 +13,25 @@ export const LinkTypeEnum = z.enum(['WEBSITE', 'GITHUB', 'HUGGINGFACE', 'PAPER']
|
|||||||
|
|
||||||
export const ExternalLinkSchema = z.object({
|
export const ExternalLinkSchema = z.object({
|
||||||
type: LinkTypeEnum,
|
type: LinkTypeEnum,
|
||||||
url: z.string()
|
url: z
|
||||||
.min(1, 'URL is required')
|
.string()
|
||||||
.max(2000, 'URL is too long')
|
.min(1, "URL is required")
|
||||||
|
.max(2000, "URL is too long")
|
||||||
.refine((url) => {
|
.refine((url) => {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url)
|
const parsed = new URL(url);
|
||||||
return ['http:', 'https:'].includes(parsed.protocol)
|
return ["http:", "https:"].includes(parsed.protocol);
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}, 'URL must use http or https protocol'),
|
}, "URL must use http or https protocol"),
|
||||||
title: z.string().max(200).optional()
|
title: z.string().max(200).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const TagSchema = z.object({
|
export const TagSchema = z.object({
|
||||||
name: z.string().min(1).max(50),
|
name: z.string().min(1).max(50),
|
||||||
nameEn: z.string().max(50).optional()
|
nameEn: z.string().max(50).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Project Schemas
|
// Project Schemas
|
||||||
@@ -43,39 +44,43 @@ export const ProjectBaseSchema = z.object({
|
|||||||
descriptionEn: z.string().max(500).optional(),
|
descriptionEn: z.string().max(500).optional(),
|
||||||
content: z.string().max(10000).optional(),
|
content: z.string().max(10000).optional(),
|
||||||
contentEn: z.string().max(10000).optional(),
|
contentEn: z.string().max(10000).optional(),
|
||||||
status: ProjectStatusEnum.default('ACTIVE'),
|
status: ProjectStatusEnum.default("ACTIVE"),
|
||||||
source: z.string().max(100).optional()
|
source: z.string().max(100).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const ProjectInputSchema = ProjectBaseSchema.extend({
|
export const ProjectInputSchema = ProjectBaseSchema.extend({
|
||||||
tags: z.array(TagSchema).min(1, 'At least one tag 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)
|
links: z.array(ExternalLinkSchema).min(1, "At least one link is required").max(10),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Webhook Schemas
|
// Webhook 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({
|
export const WebhookPayloadSchema = WebhookAuthSchema.extend({
|
||||||
projects: z.array(ProjectInputSchema).min(1).max(100)
|
projects: z.array(ProjectInputSchema).min(1).max(100),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Discovery Task Schemas
|
// 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({
|
export const CreateDiscoveryTaskSchema = WebhookAuthSchema.extend({
|
||||||
tasks: z.array(z.object({
|
tasks: z
|
||||||
sourceUrl: z.string().url().max(2000),
|
.array(
|
||||||
sourceType: z.string().max(50).default('manual'),
|
z.object({
|
||||||
})).min(1)
|
sourceUrl: z.string().url().max(2000),
|
||||||
})
|
sourceType: z.string().max(50).default("manual"),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1),
|
||||||
|
});
|
||||||
|
|
||||||
export const UpdateDiscoveryTaskSchema = z.object({
|
export const UpdateDiscoveryTaskSchema = z.object({
|
||||||
apiKey: z.string().min(32),
|
apiKey: z.string().min(32),
|
||||||
@@ -83,13 +88,13 @@ export const UpdateDiscoveryTaskSchema = z.object({
|
|||||||
explorationData: z.record(z.unknown()).optional(),
|
explorationData: z.record(z.unknown()).optional(),
|
||||||
explorationSummary: z.string().max(1000).optional(),
|
explorationSummary: z.string().max(1000).optional(),
|
||||||
errorMessage: z.string().max(2000).optional(),
|
errorMessage: z.string().max(2000).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const GetDiscoveryTasksQuerySchema = z.object({
|
export const GetDiscoveryTasksQuerySchema = z.object({
|
||||||
status: TaskStatusEnum.optional(),
|
status: TaskStatusEnum.optional(),
|
||||||
limit: z.coerce.number().int().positive().max(100).default(10),
|
limit: z.coerce.number().int().positive().max(100).default(10),
|
||||||
offset: z.coerce.number().int().nonnegative().default(0),
|
offset: z.coerce.number().int().nonnegative().default(0),
|
||||||
})
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量重置任务 Schema
|
* 批量重置任务 Schema
|
||||||
@@ -100,10 +105,9 @@ export const BatchResetTasksSchema = WebhookAuthSchema.extend({
|
|||||||
taskIds: z.array(z.string()).optional(),
|
taskIds: z.array(z.string()).optional(),
|
||||||
// 模式2: 按状态筛选(不传则默认重置 IN_PROGRESS 和 FAILED)
|
// 模式2: 按状态筛选(不传则默认重置 IN_PROGRESS 和 FAILED)
|
||||||
statuses: z.array(TaskStatusEnum).optional(),
|
statuses: z.array(TaskStatusEnum).optional(),
|
||||||
}).refine(
|
}).refine((data) => data.taskIds || data.statuses, {
|
||||||
(data) => data.taskIds || data.statuses,
|
message: "必须提供 taskIds 或 statuses 之一",
|
||||||
{ message: '必须提供 taskIds 或 statuses 之一' }
|
});
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查任务去重 Schema
|
* 检查任务去重 Schema
|
||||||
@@ -112,7 +116,7 @@ export const BatchResetTasksSchema = WebhookAuthSchema.extend({
|
|||||||
export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
|
export const CheckTaskDuplicatesSchema = WebhookAuthSchema.extend({
|
||||||
urls: z.array(z.string().url().max(2000)).min(1).max(100),
|
urls: z.array(z.string().url().max(2000)).min(1).max(100),
|
||||||
sourceType: z.string().max(50).optional(),
|
sourceType: z.string().max(50).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Query Schemas
|
// Query Schemas
|
||||||
@@ -123,23 +127,23 @@ export const ProjectQuerySchema = z.object({
|
|||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
status: ProjectStatusEnum.optional(),
|
status: ProjectStatusEnum.optional(),
|
||||||
page: z.coerce.number().int().positive().default(1),
|
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
|
// Types
|
||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
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 WebhookPayload = z.infer<typeof WebhookPayloadSchema>
|
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 TaskStatus = z.infer<typeof TaskStatusEnum>;
|
||||||
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>
|
export type CreateDiscoveryTask = z.infer<typeof CreateDiscoveryTaskSchema>;
|
||||||
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>
|
export type UpdateDiscoveryTask = z.infer<typeof UpdateDiscoveryTaskSchema>;
|
||||||
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>
|
export type GetDiscoveryTasksQuery = z.infer<typeof GetDiscoveryTasksQuerySchema>;
|
||||||
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>
|
export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>;
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Keyword Cloud Schemas
|
// Keyword Cloud Schemas
|
||||||
@@ -147,11 +151,15 @@ export type BatchResetTasks = z.infer<typeof BatchResetTasksSchema>
|
|||||||
|
|
||||||
// 视觉配置 Schema
|
// 视觉配置 Schema
|
||||||
const VisualConfigSchema = z.object({
|
const VisualConfigSchema = z.object({
|
||||||
color: z.enum(['primary', 'secondary', 'accent', 'gray']),
|
color: z.enum(["primary", "secondary", "accent", "gray"]),
|
||||||
size: z.enum(['text-5xl', 'text-4xl', 'text-3xl', 'text-2xl', 'text-xl', 'text-lg', 'text-base']),
|
size: z.enum(["text-5xl", "text-4xl", "text-3xl", "text-2xl", "text-xl", "text-lg", "text-base"]),
|
||||||
border: z.enum(['border-4', 'border-2']),
|
border: z.enum(["border-4", "border-2"]),
|
||||||
rotation: z.string().regex(/^-?rotate-\d+$/).nullable().optional(),
|
rotation: z
|
||||||
})
|
.string()
|
||||||
|
.regex(/^-?rotate-\d+$/)
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
// 关键词输入 Schema
|
// 关键词输入 Schema
|
||||||
export const KeywordInputSchema = z.object({
|
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),
|
detailPoints: z.array(z.string().min(5).max(100)).min(1).max(5),
|
||||||
detailPointsEn: z.array(z.string().max(100)).max(5).optional(),
|
detailPointsEn: z.array(z.string().max(100)).max(5).optional(),
|
||||||
visualConfig: VisualConfigSchema,
|
visualConfig: VisualConfigSchema,
|
||||||
})
|
});
|
||||||
|
|
||||||
// 批量写入关键词请求 Schema
|
// 批量写入关键词请求 Schema
|
||||||
export const BatchKeywordsRequestSchema = z.object({
|
export const BatchKeywordsRequestSchema = z.object({
|
||||||
quarter: z.string().regex(/^\d{4}-Q[1-4]$/, "格式应为 YYYY-QN"),
|
quarter: z.string().regex(/^\d{4}-Q[1-4]$/, "格式应为 YYYY-QN"),
|
||||||
keywords: z.array(KeywordInputSchema).min(1).max(50),
|
keywords: z.array(KeywordInputSchema).min(1).max(50),
|
||||||
})
|
});
|
||||||
|
|
||||||
// 季度 Schema
|
// 季度 Schema
|
||||||
export const QuarterSchema = z.object({
|
export const QuarterSchema = z.object({
|
||||||
@@ -179,29 +187,43 @@ export const QuarterSchema = z.object({
|
|||||||
subtitleEn: z.string().max(500).optional(),
|
subtitleEn: z.string().max(500).optional(),
|
||||||
displayOrder: z.number().int().min(0).default(0),
|
displayOrder: z.number().int().min(0).default(0),
|
||||||
isActive: z.boolean().default(true),
|
isActive: z.boolean().default(true),
|
||||||
})
|
});
|
||||||
|
|
||||||
// 视觉规则 Schema
|
// 视觉规则 Schema
|
||||||
export const VisualStyleRuleSchema = z.object({
|
export const VisualStyleRuleSchema = z
|
||||||
name: z.string().min(1).max(100),
|
.object({
|
||||||
minScore: z.number().int().min(0).max(100),
|
name: z.string().min(1).max(100),
|
||||||
maxScore: z.number().int().min(0).max(100),
|
minScore: z.number().int().min(0).max(100),
|
||||||
color: z.enum(['primary', 'secondary', 'accent', 'gray']),
|
maxScore: z.number().int().min(0).max(100),
|
||||||
size: z.enum(['text-5xl', 'text-4xl', 'text-3xl', 'text-2xl', 'text-xl', 'text-lg', 'text-base']),
|
color: z.enum(["primary", "secondary", "accent", "gray"]),
|
||||||
border: z.enum(['border-4', 'border-2']),
|
size: z.enum([
|
||||||
rotation: z.string().regex(/^-?rotate-\d+$/).nullable().optional(),
|
"text-5xl",
|
||||||
priority: z.number().int().min(0).default(0),
|
"text-4xl",
|
||||||
enabled: z.boolean().default(true),
|
"text-3xl",
|
||||||
}).refine(data => data.minScore < data.maxScore, {
|
"text-2xl",
|
||||||
message: "minScore 必须小于 maxScore",
|
"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
|
// API 响应 Schema
|
||||||
export const KeywordCloudResponseSchema = z.object({
|
export const KeywordCloudResponseSchema = z.object({
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
data: z.any().optional(),
|
data: z.any().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// AI Timeline Schemas
|
// AI Timeline Schemas
|
||||||
@@ -215,22 +237,62 @@ export const AIEventInputSchema = z.object({
|
|||||||
descriptionEn: z.string().max(500).optional(),
|
descriptionEn: z.string().max(500).optional(),
|
||||||
imageUrl: z.string().url(),
|
imageUrl: z.string().url(),
|
||||||
sourceUrl: z.string().url().optional(),
|
sourceUrl: z.string().url().optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
export const AIEventQuerySchema = z.object({
|
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(),
|
limit: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||||
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
|
offset: z.string().regex(/^\d+$/).transform(Number).optional(),
|
||||||
})
|
});
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// Types
|
// Types
|
||||||
// ================================
|
// ================================
|
||||||
|
|
||||||
export type KeywordInput = z.infer<typeof KeywordInputSchema>
|
export type KeywordInput = z.infer<typeof KeywordInputSchema>;
|
||||||
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>
|
export type BatchKeywordsRequest = z.infer<typeof BatchKeywordsRequestSchema>;
|
||||||
export type Quarter = z.infer<typeof QuarterSchema>
|
export type Quarter = z.infer<typeof QuarterSchema>;
|
||||||
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>
|
export type VisualStyleRule = z.infer<typeof VisualStyleRuleSchema>;
|
||||||
export type AIEventInput = z.infer<typeof AIEventInputSchema>
|
export type AIEventInput = z.infer<typeof AIEventInputSchema>;
|
||||||
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>
|
export type AIEventQuery = z.infer<typeof AIEventQuerySchema>;
|
||||||
export type KeywordCloudResponse = z.infer<typeof KeywordCloudResponseSchema>
|
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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user