feat: 实现标签合并维护接口与n8n每日工作流
This commit is contained in:
@@ -53,7 +53,7 @@ RAG项目搜索 ← User Query
|
||||
|
||||
- `POST /api/discovery/tasks` - Create discovery tasks
|
||||
- `POST /api/keyword-cloud/keywords` - Bulk keyword upload
|
||||
- `PATCH /api/tags/maintenance` - Tag cleanup (n8n integration)
|
||||
- `POST /api/tags/maintenance` - Tag cleanup (n8n integration)
|
||||
|
||||
### Environment Variables (n8n)
|
||||
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
本目录包含 Agent Park 的 n8n 工作流配置。
|
||||
|
||||
## 工作流清单
|
||||
|
||||
| 文件 | 功能 |
|
||||
| --- | --- |
|
||||
| `keyword-cloud-workflow.json` | Google Trends → 关键词词云入库 |
|
||||
| `tag-janitor-workflow.json` | 每日 AI 标签合并与 `nameEn` 补全 |
|
||||
|
||||
## 关键词词云工作流
|
||||
|
||||
**文件**: `keyword-cloud-workflow.json`
|
||||
@@ -56,6 +63,29 @@
|
||||
- `GET {API_URL}/api/keyword-cloud/rules` - 获取视觉规则
|
||||
- `POST {API_URL}/api/keyword-cloud/keywords` - 批量写入关键词
|
||||
|
||||
## Tag Janitor 工作流
|
||||
|
||||
**文件**: `tag-janitor-workflow.json`
|
||||
|
||||
### 功能
|
||||
|
||||
每日自动拉取标签,利用 LLM 生成 `merges + updates` 计划,并调用维护 API 执行标签合并。
|
||||
|
||||
### 执行流程
|
||||
|
||||
1. **Schedule Trigger**: 每日 UTC 03:00 触发
|
||||
2. **Fetch Tags**: 调用 `GET /api/tags` 获取标签和项目计数
|
||||
3. **Preprocess Tags**: 预处理排序(按 projectCount)
|
||||
4. **AI Analyze Tags**: 生成严格 JSON 计划(`merges`、`updates`)
|
||||
5. **Validate JSON**: 校验输出结构和自合并风险
|
||||
6. **Execute Maintenance**: 调用 `POST /api/tags/maintenance` 执行
|
||||
7. **Summarize Results**: 输出日报(merged/deleted/updated)
|
||||
|
||||
### API 端点
|
||||
|
||||
- `GET {API_URL}/api/tags` - 拉取标签列表(含 `_count.projects`)
|
||||
- `POST {API_URL}/api/tags/maintenance` - 执行批量 updates/merges
|
||||
|
||||
### 调试
|
||||
|
||||
检查每个节点的输出,确保数据格式正确:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ export async function POST(request: NextRequest) {
|
||||
// 1. API Key 验证
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const expectedKey = process.env.WEBHOOK_API_KEY;
|
||||
const providedBuf = Buffer.from(apiKey || '');
|
||||
const expectedBuf = Buffer.from(expectedKey || '');
|
||||
|
||||
if (!apiKey || !expectedKey || !crypto.timingSafeEqual(
|
||||
Buffer.from(apiKey),
|
||||
Buffer.from(expectedKey)
|
||||
)) {
|
||||
if (
|
||||
!apiKey ||
|
||||
!expectedKey ||
|
||||
providedBuf.length !== expectedBuf.length ||
|
||||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -1,148 +1,154 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { POST } from "./route";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
const tx = {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
projectTag: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
|
||||
revalidatePathMock: vi.fn(),
|
||||
txMock: tx,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
$transaction: transactionMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
function buildRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/tags/maintenance", () => {
|
||||
const validApiKey = "k".repeat(32);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WEBHOOK_API_KEY = "test-key-32-characters-long!!";
|
||||
process.env.WEBHOOK_API_KEY = validApiKey;
|
||||
transactionMock.mockClear();
|
||||
revalidatePathMock.mockClear();
|
||||
txMock.tag.findMany.mockReset();
|
||||
txMock.tag.findUnique.mockReset();
|
||||
txMock.tag.update.mockReset();
|
||||
txMock.tag.create.mockReset();
|
||||
txMock.tag.deleteMany.mockReset();
|
||||
txMock.projectTag.findMany.mockReset();
|
||||
txMock.projectTag.createMany.mockReset();
|
||||
});
|
||||
|
||||
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({
|
||||
it("returns 401 for wrong API key", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: "a".repeat(32),
|
||||
updates: [],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
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: "" }],
|
||||
it("returns 400 for schema errors (missing nameEn)", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [{ tagId: "tag-1" }],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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!!",
|
||||
it("returns 400 for self merge payload", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
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",
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-tag" },
|
||||
sourceTagIds: ["target-tag"],
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "test-key-32-characters-long!!",
|
||||
updates: [{ tagId: "test-id", nameEn: "Test Tag English" }],
|
||||
],
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 200 for authorized empty operations", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [],
|
||||
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);
|
||||
expect(json.result).toEqual({
|
||||
updatedCount: 0,
|
||||
mergedCount: 0,
|
||||
deletedTagCount: 0,
|
||||
});
|
||||
expect(revalidatePathMock).toHaveBeenCalledTimes(2);
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page");
|
||||
});
|
||||
|
||||
it("returns 400 for unknown tag IDs", async () => {
|
||||
txMock.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(json.details).toContain("Unknown tagId: missing-tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from "crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { TagMaintenanceRequestSchema } from "@/lib/validations";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,105 +45,10 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 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)
|
||||
const result = await prisma.$transaction((tx) =>
|
||||
executeTagMaintenance(tx, { updates, merges })
|
||||
);
|
||||
|
||||
// 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");
|
||||
@@ -153,6 +58,17 @@ export async function POST(request: NextRequest) {
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TagMaintenanceApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[POST /api/tags/maintenance] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
|
||||
function createTxMock() {
|
||||
return {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
projectTag: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeTagMaintenance", () => {
|
||||
it("deduplicates projectTag migration and deletes source tags", async () => {
|
||||
const tx = createTxMock();
|
||||
tx.tag.findMany.mockResolvedValue([
|
||||
{ id: "target-tag" },
|
||||
{ id: "source-1" },
|
||||
{ id: "source-2" },
|
||||
]);
|
||||
tx.tag.update.mockResolvedValue({ id: "target-tag" });
|
||||
tx.projectTag.findMany.mockResolvedValue([
|
||||
{ projectId: "project-1" },
|
||||
{ projectId: "project-2" },
|
||||
{ projectId: "project-2" },
|
||||
]);
|
||||
tx.projectTag.createMany.mockResolvedValue({ count: 2 });
|
||||
tx.tag.deleteMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-tag", nameEn: "Machine Learning" },
|
||||
sourceTagIds: ["source-1", "source-2"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tx.tag.update).toHaveBeenCalledWith({
|
||||
where: { id: "target-tag" },
|
||||
data: { nameEn: "Machine Learning" },
|
||||
});
|
||||
expect(tx.projectTag.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ projectId: "project-1", tagId: "target-tag" },
|
||||
{ projectId: "project-2", tagId: "target-tag" },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(tx.tag.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["source-1", "source-2"] } },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
mergedCount: 1,
|
||||
deletedTagCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 400 for invalid tag IDs", async () => {
|
||||
const tx = createTxMock();
|
||||
tx.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
).rejects.toMatchObject<TagMaintenanceApiError>({
|
||||
status: 400,
|
||||
message: "Validation error",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { generateSlug } from '@/lib/slug'
|
||||
import type { MergeTarget, TagMaintenanceRequest } from '@/lib/validations'
|
||||
|
||||
type MaintenancePayload = Omit<TagMaintenanceRequest, 'apiKey'>
|
||||
type TransactionClient = Prisma.TransactionClient
|
||||
|
||||
export type TagMaintenanceResult = {
|
||||
updatedCount: number
|
||||
mergedCount: number
|
||||
deletedTagCount: number
|
||||
}
|
||||
|
||||
export class TagMaintenanceApiError extends Error {
|
||||
status: number
|
||||
details: string[]
|
||||
|
||||
constructor(status: number, message: string, details: string[] = []) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.details = details
|
||||
this.name = 'TagMaintenanceApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function isMergeTargetById(target: MergeTarget): target is Extract<MergeTarget, { id: string }> {
|
||||
return 'id' in target
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
)
|
||||
}
|
||||
|
||||
function collectReferencedTagIds(payload: MaintenancePayload): string[] {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const update of payload.updates) {
|
||||
ids.add(update.tagId)
|
||||
}
|
||||
|
||||
for (const merge of payload.merges) {
|
||||
for (const sourceTagId of merge.sourceTagIds) {
|
||||
ids.add(sourceTagId)
|
||||
}
|
||||
if (isMergeTargetById(merge.target)) {
|
||||
ids.add(merge.target.id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
async function assertTagIdsExist(
|
||||
tx: TransactionClient,
|
||||
tagIds: string[]
|
||||
): Promise<void> {
|
||||
if (tagIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingTags = await tx.tag.findMany({
|
||||
where: { id: { in: tagIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
const existingTagIds = new Set(existingTags.map((tag) => tag.id))
|
||||
|
||||
const missingTagIds = tagIds.filter((tagId) => !existingTagIds.has(tagId))
|
||||
if (missingTagIds.length > 0) {
|
||||
throw new TagMaintenanceApiError(
|
||||
400,
|
||||
'Validation error',
|
||||
missingTagIds.map((tagId) => `Unknown tagId: ${tagId}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetTagId(
|
||||
tx: TransactionClient,
|
||||
target: MergeTarget
|
||||
): Promise<string> {
|
||||
if (isMergeTargetById(target)) {
|
||||
if (target.name !== undefined || target.nameEn !== undefined) {
|
||||
const data: { name?: string; nameEn?: string } = {}
|
||||
if (target.name !== undefined) {
|
||||
data.name = target.name
|
||||
}
|
||||
if (target.nameEn !== undefined) {
|
||||
data.nameEn = target.nameEn
|
||||
}
|
||||
|
||||
try {
|
||||
await tx.tag.update({
|
||||
where: { id: target.id },
|
||||
data,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
['Failed to update target tag due to name or slug conflict']
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return target.id
|
||||
}
|
||||
|
||||
const slug = generateSlug(target.name, target.nameEn)
|
||||
const existingByName = await tx.tag.findUnique({
|
||||
where: { name: target.name },
|
||||
select: { id: true, nameEn: true },
|
||||
})
|
||||
|
||||
if (existingByName) {
|
||||
if (existingByName.nameEn !== target.nameEn) {
|
||||
await tx.tag.update({
|
||||
where: { id: existingByName.id },
|
||||
data: { nameEn: target.nameEn },
|
||||
})
|
||||
}
|
||||
return existingByName.id
|
||||
}
|
||||
|
||||
const existingBySlug = await tx.tag.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true },
|
||||
})
|
||||
if (existingBySlug) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
[`Target slug "${slug}" already exists on tag "${existingBySlug.name}"`]
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await tx.tag.create({
|
||||
data: {
|
||||
name: target.name,
|
||||
nameEn: target.nameEn,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
return created.id
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
['Failed to create target tag due to unique constraint conflict']
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateProjectTags(
|
||||
tx: TransactionClient,
|
||||
targetTagId: string,
|
||||
sourceTagIds: string[]
|
||||
): Promise<void> {
|
||||
const sourceProjectTags = await tx.projectTag.findMany({
|
||||
where: { tagId: { in: sourceTagIds } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const uniqueProjectIds = [...new Set(sourceProjectTags.map((tag) => tag.projectId))]
|
||||
|
||||
if (uniqueProjectIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await tx.projectTag.createMany({
|
||||
data: uniqueProjectIds.map((projectId) => ({
|
||||
projectId,
|
||||
tagId: targetTagId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeTagMaintenance(
|
||||
tx: TransactionClient,
|
||||
payload: MaintenancePayload
|
||||
): Promise<TagMaintenanceResult> {
|
||||
const referencedTagIds = collectReferencedTagIds(payload)
|
||||
await assertTagIdsExist(tx, referencedTagIds)
|
||||
|
||||
let updatedCount = 0
|
||||
let mergedCount = 0
|
||||
let deletedTagCount = 0
|
||||
|
||||
for (const update of payload.updates) {
|
||||
await tx.tag.update({
|
||||
where: { id: update.tagId },
|
||||
data: { nameEn: update.nameEn },
|
||||
})
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
for (const merge of payload.merges) {
|
||||
const targetTagId = await resolveTargetTagId(tx, merge.target)
|
||||
const sourceTagIds = [...new Set(merge.sourceTagIds)].filter(
|
||||
(sourceTagId) => sourceTagId !== targetTagId
|
||||
)
|
||||
|
||||
if (sourceTagIds.length === 0) {
|
||||
mergedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
await migrateProjectTags(tx, targetTagId, sourceTagIds)
|
||||
|
||||
const deleteResult = await tx.tag.deleteMany({
|
||||
where: { id: { in: sourceTagIds } },
|
||||
})
|
||||
deletedTagCount += deleteResult.count
|
||||
mergedCount++
|
||||
}
|
||||
|
||||
return {
|
||||
updatedCount,
|
||||
mergedCount,
|
||||
deletedTagCount,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { findManyMock } = vi.hoisted(() => ({
|
||||
findManyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
tag: {
|
||||
findMany: findManyMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GET /api/tags", () => {
|
||||
it("should return success with tags array", async () => {
|
||||
beforeEach(() => {
|
||||
findManyMock.mockReset();
|
||||
});
|
||||
|
||||
it("returns tags list with project count", async () => {
|
||||
findManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "tag-1",
|
||||
name: "机器学习",
|
||||
nameEn: "Machine Learning",
|
||||
slug: "machine-learning",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
_count: { projects: 4 },
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await GET();
|
||||
const json = await response.json();
|
||||
|
||||
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);
|
||||
expect(json.tags).toHaveLength(1);
|
||||
expect(json.tags[0]).toMatchObject({
|
||||
id: "tag-1",
|
||||
slug: "machine-learning",
|
||||
_count: { projects: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("should include _count.projects in tags", async () => {
|
||||
it("returns 500 when prisma query fails", async () => {
|
||||
findManyMock.mockRejectedValue(new Error("db unavailable"));
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Internal server error");
|
||||
expect(json.details).toContain("db unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TagMaintenanceRequestSchema } from "./validations";
|
||||
|
||||
describe("TagMaintenanceRequestSchema", () => {
|
||||
const apiKey = "k".repeat(32);
|
||||
|
||||
it("accepts empty updates and merges", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate update tag IDs", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [
|
||||
{ tagId: "tag-1", nameEn: "Tag 1" },
|
||||
{ tagId: "tag-1", nameEn: "Tag One" },
|
||||
],
|
||||
merges: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Duplicate update tag ID");
|
||||
});
|
||||
|
||||
it("rejects self merge", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "tag-1" },
|
||||
sourceTagIds: ["tag-1", "tag-2"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Self merge is not allowed");
|
||||
});
|
||||
|
||||
it("rejects one source tag used in multiple merges", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-1" },
|
||||
sourceTagIds: ["source-1"],
|
||||
},
|
||||
{
|
||||
target: { id: "target-2" },
|
||||
sourceTagIds: ["source-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Source tag ID appears in multiple merges");
|
||||
});
|
||||
});
|
||||
+63
-7
@@ -269,23 +269,79 @@ export const TagUpdateSchema = z.object({
|
||||
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),
|
||||
}),
|
||||
]);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
watch: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user