import { NextResponse } from "next/server"; import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; import { buildSnippet, highlightAll, parseSearchQuery, termToPgPattern, } from "@/lib/search"; import { isVideoContentTag } from "@/lib/video-tags"; type SearchRow = { awemeId: string; type: "video" | "image"; score: number; inDesc: boolean; inTranscript: boolean; rawDesc: string; rawTranscript: string; contentTags: string[]; total: number; }; export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); const rawTag = (searchParams.get("tag") || "").trim(); const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null; const page = Math.max(1, Math.floor(Number(searchParams.get("page")) || 1)); const limit = Math.min( 50, Math.max(1, Math.floor(Number(searchParams.get("limit")) || 20)), ); const offset = (page - 1) * limit; if (rawTag && !contentTag) { return NextResponse.json({ error: "Invalid tag" }, { status: 400 }); } const { terms, excludes } = parseSearchQuery(q); if (terms.length === 0 && !contentTag) { return json({ results: [], total: 0, page, limit, terms }); } // 关键词已在 parseSearchQuery 里转为小写,这里转成正则后与 lower() 后的文本匹配 const patterns = terms.map(termToPgPattern); const excludePatterns = excludes.map(termToPgPattern); try { // 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写), // 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。 // 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。 // 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。 const rows = await prisma.$queryRaw` WITH params AS ( SELECT ${patterns}::text[] AS terms, ${excludePatterns}::text[] AS excludes ), docs AS ( SELECT v.aweme_id AS "awemeId", 'video'::text AS type, v."desc" AS "rawDesc", COALESCE(array_to_string(vt.transcript, E'\n'), '') AS "rawTranscript", v.created_at AS created, v.manual_content_tags AS "contentTags" FROM "Video" v LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id WHERE ${contentTag}::text IS NULL OR v.manual_content_tags @> ARRAY[${contentTag}]::text[] UNION ALL SELECT ip.aweme_id, 'image'::text, ip."desc", '', ip.created_at, ARRAY[]::text[] FROM "ImagePost" ip WHERE ${contentTag}::text IS NULL ), lowered AS ( SELECT docs.*, lower(docs."rawDesc") AS d, lower(docs."rawTranscript") AS t FROM docs ), matched AS ( SELECT l.*, COALESCE(( SELECT SUM( 4 * LEAST(regexp_count(l.d, term), 3) + LEAST(regexp_count(l.t, term), 10) ) FROM unnest(p.terms) term ), 0)::float8 AS score, EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.d ~ term) AS "inDesc", EXISTS (SELECT 1 FROM unnest(p.terms) term WHERE l.t ~ term) AS "inTranscript" FROM lowered l, params p WHERE NOT EXISTS ( SELECT 1 FROM unnest(p.terms) term WHERE l.d !~ term AND l.t !~ term ) AND NOT EXISTS ( SELECT 1 FROM unnest(p.excludes) x WHERE l.d ~ x OR l.t ~ x ) ) SELECT "awemeId", type, score, "inDesc", "inTranscript", "rawDesc", "rawTranscript", "contentTags", (COUNT(*) OVER ())::int AS total FROM matched ORDER BY score DESC, created DESC, "awemeId" OFFSET ${offset} LIMIT ${limit}; `; const videoIds = rows .filter((r) => r.type === "video") .map((r) => r.awemeId); const imagePostIds = rows .filter((r) => r.type === "image") .map((r) => r.awemeId); const [videos, imagePosts] = await Promise.all([ videoIds.length > 0 ? prisma.video.findMany({ where: { aweme_id: { in: videoIds } }, select: { aweme_id: true, desc: true, cover_url: true, video_url: true, duration_ms: true, author: true, }, }) : [], imagePostIds.length > 0 ? prisma.imagePost.findMany({ where: { aweme_id: { in: imagePostIds } }, select: { aweme_id: true, desc: true, author: true, images: { orderBy: { order: "asc" }, take: 1, select: { url: true, width: true, height: true }, }, }, }) : [], ]); const videoMap = new Map( videos.map((v) => [ v.aweme_id, { ...v, cover_url: getFileUrl(v.cover_url || ""), video_url: getFileUrl(v.video_url || ""), author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || ""), }, }, ]), ); const imagePostMap = new Map( imagePosts.map((ip) => [ ip.aweme_id, { ...ip, author: { ...ip.author, avatar_url: getFileUrl(ip.author.avatar_url || ""), }, cover_url: ip.images[0] ? getFileUrl(ip.images[0].url) : null, }, ]), ); return json({ results: rows.map((r) => { const matchedIn: ("desc" | "transcript")[] = []; if (r.inDesc) matchedIn.push("desc"); if (r.inTranscript) matchedIn.push("transcript"); return { id: `${r.type}:${r.awemeId}`, awemeId: r.awemeId, type: r.type, score: r.score, matchedIn, contentTags: r.contentTags, descParts: highlightAll(r.rawDesc, terms), // 转写命中时给出命中片段;只按标签筛选时给出转写开头作为预览 snippet: r.inTranscript || (terms.length === 0 && r.rawTranscript) ? buildSnippet(r.rawTranscript, terms) : null, video: r.type === "video" ? videoMap.get(r.awemeId) : undefined, imagePost: r.type === "image" ? imagePostMap.get(r.awemeId) : undefined, }; }), total: rows[0]?.total ?? 0, page, limit, terms, }); } catch (err) { console.error("Search error:", err); return NextResponse.json({ error: "Search failed" }, { status: 500 }); } }