From b78063739b128fece501916d618f857c94ef923d Mon Sep 17 00:00:00 2001 From: kurihada Date: Mon, 2 Mar 2026 10:55:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=9B=B2=E7=9B=92=E6=83=B3=E6=B3=95?= =?UTF-8?q?=E6=89=93=E6=A0=87=E6=94=B9=E4=B8=BA=E5=90=8E=E5=8F=B0=E5=BC=82?= =?UTF-8?q?=E6=AD=A5=EF=BC=8C=E6=96=B0=E5=A2=9E=20retag=20=E8=A1=A5?= =?UTF-8?q?=E6=89=93=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applyTags 改为 fire-and-forget,用户投入想法立即返回(<200ms) - 打标超时设为 60s,失败静默,由 /api/blindbox/retag 兜底 - 新增 POST /api/blindbox/retag:补打房间内所有无标签想法 --- src/app/api/blindbox/retag/route.test.ts | 71 +++++++++++++++++++++++ src/app/api/blindbox/retag/route.ts | 45 +++++++++++++++ src/app/api/blindbox/route.test.ts | 1 - src/app/api/blindbox/route.ts | 73 +++++++++--------------- 4 files changed, 143 insertions(+), 47 deletions(-) create mode 100644 src/app/api/blindbox/retag/route.test.ts create mode 100644 src/app/api/blindbox/retag/route.ts diff --git a/src/app/api/blindbox/retag/route.test.ts b/src/app/api/blindbox/retag/route.test.ts new file mode 100644 index 0000000..6ca0089 --- /dev/null +++ b/src/app/api/blindbox/retag/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/blindbox/retag/route.ts b/src/app/api/blindbox/retag/route.ts new file mode 100644 index 0000000..a528829 --- /dev/null +++ b/src/app/api/blindbox/retag/route.ts @@ -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 }); +}); diff --git a/src/app/api/blindbox/route.test.ts b/src/app/api/blindbox/route.test.ts index ceb9503..31da94b 100644 --- a/src/app/api/blindbox/route.test.ts +++ b/src/app/api/blindbox/route.test.ts @@ -42,7 +42,6 @@ describe("POST /api/blindbox (create idea)", () => { expect(status).toBe(201); expect(data.id).toBe("idea-1"); - expect(data.tags).toBeDefined(); }); it("returns 401 when no userId", async () => { diff --git a/src/app/api/blindbox/route.ts b/src/app/api/blindbox/route.ts index ad406b4..127ede3 100644 --- a/src/app/api/blindbox/route.ts +++ b/src/app/api/blindbox/route.ts @@ -5,7 +5,29 @@ import { apiHandler, ApiError, requireUserId } from "@/lib/api"; import { validateIdeaContent, requireString } from "@/lib/validation"; 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((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) => { const { roomId, userId, content } = await req.json(); @@ -20,31 +42,9 @@ export const POST = apiHandler(async (req) => { data: { roomId, userId, content: trimmedContent }, }); - const tags = await Promise.race([ - tagIdea(trimmedContent), - new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), - ]); + applyTags(idea.id, trimmedContent); - 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, - }, - }); - } - - return NextResponse.json( - { id: idea.id, ...tags && { tags } }, - { status: 201 }, - ); + return NextResponse.json({ id: idea.id }, { status: 201 }); }); export const GET = apiHandler(async (req) => { @@ -101,28 +101,9 @@ export const PUT = apiHandler(async (req) => { if (count === 0) throw new ApiError("想法不存在、已被抽中或无权编辑", 404); - const tags = await Promise.race([ - tagIdea(trimmedContent), - new Promise((resolve) => setTimeout(() => resolve(null), TAG_TIMEOUT_MS)), - ]); + applyTags(ideaId, trimmedContent); - if (tags) { - 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 } }); + return NextResponse.json({ id: ideaId, content: trimmedContent }); }); export const DELETE = apiHandler(async (req) => {