fix: 盲盒想法打标改为后台异步,新增 retag 补打接口
- applyTags 改为 fire-and-forget,用户投入想法立即返回(<200ms) - 打标超时设为 60s,失败静默,由 /api/blindbox/retag 兜底 - 新增 POST /api/blindbox/retag:补打房间内所有无标签想法
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { prismaMock, resetPrismaMock } from "@/__tests__/helpers/prisma-mock";
|
||||||
|
import { createRequest, parseJsonResponse } from "@/__tests__/helpers/api-test-utils";
|
||||||
|
import { TEST_BLINDBOX_IDEA } from "@/__tests__/helpers/fixtures";
|
||||||
|
|
||||||
|
vi.mock("@/lib/blindbox", () => ({
|
||||||
|
requireMembership: vi.fn().mockResolvedValue({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/ai", () => ({
|
||||||
|
tagIdea: vi.fn().mockResolvedValue({
|
||||||
|
category: "outdoor",
|
||||||
|
timeSlot: "morning",
|
||||||
|
estimatedMinutes: 120,
|
||||||
|
costLevel: "free",
|
||||||
|
intensity: "active",
|
||||||
|
needsBooking: false,
|
||||||
|
searchQuery: "公园",
|
||||||
|
searchType: "category",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
const mockCtx = { params: Promise.resolve({}) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetPrismaMock();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/blindbox/retag", () => {
|
||||||
|
it("retags untagged ideas and returns count", async () => {
|
||||||
|
const untaggedIdea = { ...TEST_BLINDBOX_IDEA, category: null };
|
||||||
|
prismaMock.blindBoxIdea.findMany.mockResolvedValue([untaggedIdea] as never);
|
||||||
|
prismaMock.blindBoxIdea.update.mockResolvedValue(TEST_BLINDBOX_IDEA as never);
|
||||||
|
|
||||||
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
|
method: "POST",
|
||||||
|
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||||
|
});
|
||||||
|
const res = await POST(req, mockCtx);
|
||||||
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(data.retagged).toBe(1);
|
||||||
|
expect(data.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when no untagged ideas", async () => {
|
||||||
|
prismaMock.blindBoxIdea.findMany.mockResolvedValue([] as never);
|
||||||
|
|
||||||
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
|
method: "POST",
|
||||||
|
body: { roomId: "bb-room-1", userId: "user-1" },
|
||||||
|
});
|
||||||
|
const res = await POST(req, mockCtx);
|
||||||
|
const { status, data } = await parseJsonResponse(res);
|
||||||
|
|
||||||
|
expect(status).toBe(200);
|
||||||
|
expect(data.retagged).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when no userId", async () => {
|
||||||
|
const req = createRequest("/api/blindbox/retag", {
|
||||||
|
method: "POST",
|
||||||
|
body: { roomId: "bb-room-1" },
|
||||||
|
});
|
||||||
|
const res = await POST(req, mockCtx);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { requireMembership } from "@/lib/blindbox";
|
||||||
|
import { apiHandler, requireUserId } from "@/lib/api";
|
||||||
|
import { tagIdea } from "@/lib/ai";
|
||||||
|
|
||||||
|
export const POST = apiHandler(async (req) => {
|
||||||
|
const { roomId, userId } = await req.json();
|
||||||
|
|
||||||
|
requireUserId(userId);
|
||||||
|
await requireMembership(roomId, userId);
|
||||||
|
|
||||||
|
// Find all untagged ideas in this room (any member's ideas)
|
||||||
|
const untagged = await prisma.blindBoxIdea.findMany({
|
||||||
|
where: { roomId, status: "in_pool", category: null },
|
||||||
|
select: { id: true, content: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (untagged.length === 0) {
|
||||||
|
return NextResponse.json({ retagged: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let retagged = 0;
|
||||||
|
for (const idea of untagged) {
|
||||||
|
const tags = await tagIdea(idea.content);
|
||||||
|
if (tags) {
|
||||||
|
await prisma.blindBoxIdea.update({
|
||||||
|
where: { id: idea.id },
|
||||||
|
data: {
|
||||||
|
category: tags.category,
|
||||||
|
timeSlot: tags.timeSlot,
|
||||||
|
estimatedMinutes: tags.estimatedMinutes,
|
||||||
|
costLevel: tags.costLevel,
|
||||||
|
intensity: tags.intensity,
|
||||||
|
needsBooking: tags.needsBooking,
|
||||||
|
searchQuery: tags.searchQuery,
|
||||||
|
searchType: tags.searchType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
retagged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ retagged, total: untagged.length });
|
||||||
|
});
|
||||||
@@ -42,7 +42,6 @@ describe("POST /api/blindbox (create idea)", () => {
|
|||||||
|
|
||||||
expect(status).toBe(201);
|
expect(status).toBe(201);
|
||||||
expect(data.id).toBe("idea-1");
|
expect(data.id).toBe("idea-1");
|
||||||
expect(data.tags).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 401 when no userId", async () => {
|
it("returns 401 when no userId", async () => {
|
||||||
|
|||||||
@@ -5,7 +5,29 @@ import { apiHandler, ApiError, requireUserId } from "@/lib/api";
|
|||||||
import { validateIdeaContent, requireString } from "@/lib/validation";
|
import { validateIdeaContent, requireString } from "@/lib/validation";
|
||||||
import { tagIdea } from "@/lib/ai";
|
import { tagIdea } from "@/lib/ai";
|
||||||
|
|
||||||
const TAG_TIMEOUT_MS = 3000;
|
const TAG_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
function applyTags(ideaId: string, content: string) {
|
||||||
|
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS));
|
||||||
|
Promise.race([tagIdea(content), timeout])
|
||||||
|
.then((tags) => {
|
||||||
|
if (!tags) return;
|
||||||
|
return prisma.blindBoxIdea.update({
|
||||||
|
where: { id: ideaId },
|
||||||
|
data: {
|
||||||
|
category: tags.category,
|
||||||
|
timeSlot: tags.timeSlot,
|
||||||
|
estimatedMinutes: tags.estimatedMinutes,
|
||||||
|
costLevel: tags.costLevel,
|
||||||
|
intensity: tags.intensity,
|
||||||
|
needsBooking: tags.needsBooking,
|
||||||
|
searchQuery: tags.searchQuery,
|
||||||
|
searchType: tags.searchType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export const POST = apiHandler(async (req) => {
|
export const POST = apiHandler(async (req) => {
|
||||||
const { roomId, userId, content } = await req.json();
|
const { roomId, userId, content } = await req.json();
|
||||||
@@ -20,31 +42,9 @@ export const POST = apiHandler(async (req) => {
|
|||||||
data: { roomId, userId, content: trimmedContent },
|
data: { roomId, userId, content: trimmedContent },
|
||||||
});
|
});
|
||||||
|
|
||||||
const tags = await Promise.race([
|
applyTags(idea.id, trimmedContent);
|
||||||
tagIdea(trimmedContent),
|
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (tags) {
|
return NextResponse.json({ id: idea.id }, { status: 201 });
|
||||||
await prisma.blindBoxIdea.update({
|
|
||||||
where: { id: idea.id },
|
|
||||||
data: {
|
|
||||||
category: tags.category,
|
|
||||||
timeSlot: tags.timeSlot,
|
|
||||||
estimatedMinutes: tags.estimatedMinutes,
|
|
||||||
costLevel: tags.costLevel,
|
|
||||||
intensity: tags.intensity,
|
|
||||||
needsBooking: tags.needsBooking,
|
|
||||||
searchQuery: tags.searchQuery,
|
|
||||||
searchType: tags.searchType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{ id: idea.id, ...tags && { tags } },
|
|
||||||
{ status: 201 },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GET = apiHandler(async (req) => {
|
export const GET = apiHandler(async (req) => {
|
||||||
@@ -101,28 +101,9 @@ export const PUT = apiHandler(async (req) => {
|
|||||||
|
|
||||||
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404);
|
if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404);
|
||||||
|
|
||||||
const tags = await Promise.race([
|
applyTags(ideaId, trimmedContent);
|
||||||
tagIdea(trimmedContent),
|
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (tags) {
|
return NextResponse.json({ id: ideaId, content: trimmedContent });
|
||||||
await prisma.blindBoxIdea.updateMany({
|
|
||||||
where: { id: ideaId, userId, status: "in_pool" },
|
|
||||||
data: {
|
|
||||||
category: tags.category,
|
|
||||||
timeSlot: tags.timeSlot,
|
|
||||||
estimatedMinutes: tags.estimatedMinutes,
|
|
||||||
costLevel: tags.costLevel,
|
|
||||||
intensity: tags.intensity,
|
|
||||||
needsBooking: tags.needsBooking,
|
|
||||||
searchQuery: tags.searchQuery,
|
|
||||||
searchType: tags.searchType,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ id: ideaId, content: trimmedContent, ...tags && { tags } });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const DELETE = apiHandler(async (req) => {
|
export const DELETE = apiHandler(async (req) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user