feat: 新增项目标签重置接口与n8n流程

This commit is contained in:
2026-02-22 11:58:29 +08:00
parent 93c616793d
commit 285af9a039
45 changed files with 844 additions and 11506 deletions
@@ -0,0 +1,166 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { POST } from "./route";
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
vi.hoisted(() => {
const tx = {
projectTag: {
deleteMany: vi.fn(),
createMany: vi.fn(),
},
};
return {
revalidatePathMock: vi.fn(),
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
txMock: tx,
projectFindManyMock: vi.fn(),
tagFindManyMock: vi.fn(),
};
});
vi.mock("@/lib/prisma", () => ({
prisma: {
project: {
findMany: projectFindManyMock,
},
tag: {
findMany: tagFindManyMock,
},
$transaction: transactionMock,
},
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
function buildRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost:3000/api/tags/reset-projects", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
function buildValidPayload(apiKey: string) {
return {
apiKey,
projects: [
{
projectSlug: "project-one",
selectedTagSlugsByCategory: {
FIXED_PROJECT_TYPE: ["agent-tooling"],
TECH_STACK: ["typescript"],
AI_PARADIGM: ["ai-agents"],
PRODUCT_FORM: ["web-application"],
DOMAIN_SCENARIO: ["code-dev"],
},
},
],
};
}
describe("POST /api/tags/reset-projects", () => {
const validApiKey = "k".repeat(32);
beforeEach(() => {
process.env.WEBHOOK_API_KEY = validApiKey;
transactionMock.mockClear();
revalidatePathMock.mockClear();
txMock.projectTag.deleteMany.mockReset();
txMock.projectTag.createMany.mockReset();
projectFindManyMock.mockReset();
tagFindManyMock.mockReset();
});
it("returns 401 for wrong API key", async () => {
const response = await POST(buildRequest(buildValidPayload("a".repeat(32))));
const json = await response.json();
expect(response.status).toBe(401);
expect(json.success).toBe(false);
expect(json.error).toBe("Unauthorized");
});
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
const payload = buildValidPayload(validApiKey);
payload.projects[0].selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
const response = await POST(buildRequest(payload));
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 partial failure when project is missing", async () => {
projectFindManyMock.mockResolvedValue([]);
tagFindManyMock.mockResolvedValue([
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
]);
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.success).toBe(false);
expect(json.result.failedCount).toBe(1);
expect(transactionMock).not.toHaveBeenCalled();
});
it("updates project tags and revalidates pages", async () => {
projectFindManyMock.mockResolvedValue([
{
id: "p1",
slug: "project-one",
tags: [
{
tag: {
id: "old-tech",
slug: "javascript",
category: "TECH_STACK",
},
},
{
tag: {
id: "old-domain",
slug: "automation-workflow",
category: "DOMAIN_SCENARIO",
},
},
],
},
]);
tagFindManyMock.mockResolvedValue([
{ id: "t1", slug: "agent-tooling", category: "FIXED_PROJECT_TYPE" },
{ id: "t2", slug: "typescript", category: "TECH_STACK" },
{ id: "t3", slug: "ai-agents", category: "AI_PARADIGM" },
{ id: "t4", slug: "web-application", category: "PRODUCT_FORM" },
{ id: "t5", slug: "code-dev", category: "DOMAIN_SCENARIO" },
]);
const response = await POST(buildRequest(buildValidPayload(validApiKey)));
const json = await response.json();
expect(response.status).toBe(200);
expect(json.success).toBe(true);
expect(json.result.updatedCount).toBe(1);
expect(transactionMock).toHaveBeenCalledTimes(1);
expect(txMock.projectTag.deleteMany).toHaveBeenCalledTimes(1);
expect(txMock.projectTag.createMany).toHaveBeenCalledTimes(1);
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects/project-one", "page");
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects/project-one", "page");
});
});
+293
View File
@@ -0,0 +1,293 @@
import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { revalidatePath } from 'next/cache'
import type { TagCategory } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import {
ProjectTagResetRequestSchema,
type ProjectTagResetItem,
type ResettableTagCategory,
} from '@/lib/validations'
type ResetResultItem = {
projectSlug: string
status: 'updated' | 'dry-run' | 'failed'
selectedTagCount: number
addedCount: number
removedCount: number
details: string[]
}
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
'FIXED_PROJECT_TYPE',
'TECH_STACK',
'AI_PARADIGM',
'PRODUCT_FORM',
'DOMAIN_SCENARIO',
]
function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean {
if (!expectedApiKey) {
return false
}
const providedBuf = Buffer.from(providedApiKey)
const expectedBuf = Buffer.from(expectedApiKey)
return (
providedBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(providedBuf, expectedBuf)
)
}
function normalizeSlug(slug: string): string {
return slug.trim().toLowerCase()
}
function collectSelectedTagSlugs(
item: ProjectTagResetItem,
categories: ResettableTagCategory[]
): string[] {
const selectedTagSlugSet = new Set<string>()
for (const category of categories) {
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
for (const slug of categorySlugs) {
selectedTagSlugSet.add(normalizeSlug(slug))
}
}
return [...selectedTagSlugSet]
}
function collectValidationErrorsForProjectItem(params: {
item: ProjectTagResetItem
categories: ResettableTagCategory[]
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
}): string[] {
const { item, categories, existingTagBySlug } = params
const errors: string[] = []
for (const category of categories) {
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
const normalizedSlug = normalizeSlug(rawSlug)
const existingTag = existingTagBySlug.get(normalizedSlug)
if (!existingTag) {
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
continue
}
if (existingTag.category !== category) {
errors.push(
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
)
}
}
}
return errors
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validation = ProjectTagResetRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors.map((issue) => issue.message),
},
{ status: 400 }
)
}
const {
apiKey,
dryRun,
replaceAllCategories,
projects,
categories: requestedCategories,
} = validation.data
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Invalid or missing API Key'],
},
{ status: 401 }
)
}
const categories = requestedCategories.length > 0
? requestedCategories
: DEFAULT_RESET_CATEGORIES
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
const normalizedSelectedTagSlugs = [
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
]
const [existingProjects, existingTags] = await Promise.all([
prisma.project.findMany({
where: { slug: { in: normalizedProjectSlugs } },
select: {
id: true,
slug: true,
tags: {
include: {
tag: {
select: {
id: true,
slug: true,
category: true,
},
},
},
},
},
}),
prisma.tag.findMany({
where: { slug: { in: normalizedSelectedTagSlugs } },
select: {
id: true,
slug: true,
category: true,
},
}),
])
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
const results: ResetResultItem[] = []
const updatedProjectSlugs: string[] = []
for (const item of projects) {
const projectSlug = normalizeSlug(item.projectSlug)
const project = projectBySlug.get(projectSlug)
if (!project) {
results.push({
projectSlug,
status: 'failed',
selectedTagCount: 0,
addedCount: 0,
removedCount: 0,
details: [`Project with slug "${projectSlug}" not found`],
})
continue
}
const validationErrors = collectValidationErrorsForProjectItem({
item,
categories,
existingTagBySlug,
})
if (validationErrors.length > 0) {
results.push({
projectSlug,
status: 'failed',
selectedTagCount: 0,
addedCount: 0,
removedCount: 0,
details: validationErrors,
})
continue
}
const selectedTagIds = collectSelectedTagSlugs(item, categories)
.map((slug) => existingTagBySlug.get(slug)?.id)
.filter((id): id is string => Boolean(id))
const previousCategoryTagIds = new Set(
project.tags
.filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory))
.map((projectTag) => projectTag.tag.id)
)
const nextTagIdSet = new Set(selectedTagIds)
const removedCount = replaceAllCategories
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
: 0
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
if (!dryRun) {
await prisma.$transaction(async (tx) => {
if (replaceAllCategories) {
await tx.projectTag.deleteMany({
where: {
projectId: project.id,
tag: {
category: {
in: categories as TagCategory[],
},
},
},
})
}
if (selectedTagIds.length > 0) {
await tx.projectTag.createMany({
data: selectedTagIds.map((tagId) => ({
projectId: project.id,
tagId,
})),
skipDuplicates: true,
})
}
})
updatedProjectSlugs.push(projectSlug)
}
results.push({
projectSlug,
status: dryRun ? 'dry-run' : 'updated',
selectedTagCount: selectedTagIds.length,
addedCount,
removedCount,
details: [],
})
}
const updatedCount = results.filter((item) => item.status === 'updated').length
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
const failedCount = results.filter((item) => item.status === 'failed').length
if (!dryRun && updatedProjectSlugs.length > 0) {
revalidatePath('/zh/projects', 'page')
revalidatePath('/en/projects', 'page')
for (const projectSlug of updatedProjectSlugs) {
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
revalidatePath(`/en/projects/${projectSlug}`, 'page')
}
}
return NextResponse.json({
success: failedCount === 0,
result: {
dryRun,
replaceAllCategories,
categories,
total: projects.length,
updatedCount,
dryRunCount,
failedCount,
results,
},
})
} catch (error) {
console.error('[POST /api/tags/reset-projects] Error:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
details: [error instanceof Error ? error.message : 'Unknown error'],
},
{ status: 500 }
)
}
}
+86
View File
@@ -260,6 +260,88 @@ export const TagMaintenanceRequestSchema = z.object({
});
});
// ================================
// Project Tag Reset API Schemas
// ================================
const ResettableTagCategorySchema = z.enum([
"FIXED_PROJECT_TYPE",
"TECH_STACK",
"AI_PARADIGM",
"PRODUCT_FORM",
"DOMAIN_SCENARIO",
]);
const ProjectTagSelectionByCategorySchema = z.object({
FIXED_PROJECT_TYPE: z.array(z.string().trim().min(1)).min(1).max(1),
TECH_STACK: z.array(z.string().trim().min(1)).max(20).default([]),
AI_PARADIGM: z.array(z.string().trim().min(1)).max(20).default([]),
PRODUCT_FORM: z.array(z.string().trim().min(1)).max(20).default([]),
DOMAIN_SCENARIO: z.array(z.string().trim().min(1)).max(20).default([]),
});
export const ProjectTagResetItemSchema = z.object({
projectSlug: z.string().trim().min(1).max(200),
selectedTagSlugsByCategory: ProjectTagSelectionByCategorySchema,
});
export const ProjectTagResetRequestSchema = z.object({
apiKey: z.string().min(32, "Invalid API key format"),
dryRun: z.boolean().default(false),
replaceAllCategories: z.boolean().default(true),
categories: z.array(ResettableTagCategorySchema).min(1).default([
"FIXED_PROJECT_TYPE",
"TECH_STACK",
"AI_PARADIGM",
"PRODUCT_FORM",
"DOMAIN_SCENARIO",
]),
projects: z.array(ProjectTagResetItemSchema).min(1).max(100),
}).superRefine((data, ctx) => {
const seenProjectSlugs = new Set<string>();
data.projects.forEach((project, projectIndex) => {
const normalizedSlug = project.projectSlug.trim().toLowerCase();
if (seenProjectSlugs.has(normalizedSlug)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["projects", projectIndex, "projectSlug"],
message: `Duplicate projectSlug: ${project.projectSlug}`,
});
return;
}
seenProjectSlugs.add(normalizedSlug);
const categories = Object.keys(project.selectedTagSlugsByCategory) as Array<
keyof z.infer<typeof ProjectTagSelectionByCategorySchema>
>;
categories.forEach((category) => {
const selectedSlugs = project.selectedTagSlugsByCategory[category];
const seenTagSlugs = new Set<string>();
selectedSlugs.forEach((slug, slugIndex) => {
const normalizedTagSlug = slug.trim().toLowerCase();
if (seenTagSlugs.has(normalizedTagSlug)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [
"projects",
projectIndex,
"selectedTagSlugsByCategory",
category,
slugIndex,
],
message: `Duplicate tag slug "${slug}" in category ${category}`,
});
return;
}
seenTagSlugs.add(normalizedTagSlug);
});
});
});
});
// ================================
// Types
// ================================
@@ -268,3 +350,7 @@ export type TagUpdate = z.infer<typeof TagUpdateSchema>;
export type MergeTarget = z.infer<typeof MergeTargetSchema>;
export type TagMerge = z.infer<typeof TagMergeSchema>;
export type TagMaintenanceRequest = z.infer<typeof TagMaintenanceRequestSchema>;
export type ResettableTagCategory = z.infer<typeof ResettableTagCategorySchema>;
export type ProjectTagSelectionByCategory = z.infer<typeof ProjectTagSelectionByCategorySchema>;
export type ProjectTagResetItem = z.infer<typeof ProjectTagResetItemSchema>;
export type ProjectTagResetRequest = z.infer<typeof ProjectTagResetRequestSchema>;