import { NextResponse } from "next/server"; import { isAsrContentTag } from "@/lib/asr-tags"; import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; type SearchRow = { id: string; awemeId: string; type: "video" | "image"; rank: number; snippet: string; contentTags: string[]; }; function sanitizeSnippetHtml(snippet: string) { return snippet .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("<mark>", "") .replaceAll("</mark>", ""); } 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 && isAsrContentTag(rawTag) ? rawTag : null; const page = Math.max(1, Number(searchParams.get("page") || 1)); const limit = Math.min( 50, Math.max(1, Number(searchParams.get("limit") || 20)), ); const offset = (page - 1) * limit; if (rawTag && !contentTag) { return NextResponse.json({ error: "Invalid tag" }, { status: 400 }); } if (!q && !contentTag) { return NextResponse.json({ results: [], total: 0 }); } try { // 核心查询:Postgres 中文全文检索(视频转写 + 视频desc + 图文desc)+ 相关度排序 + 摘要高亮 // 说明: // - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc // - 使用 UNION ALL 合并视频和图文的搜索结果 // - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配 const rows = q ? await prisma.$queryRaw` WITH tsq AS ( SELECT websearch_to_tsquery('zhcfg', ${q}) AS query ) -- 视频搜索结果 SELECT COALESCE(vt.id, 'desc:' || v.aweme_id) AS id, v.aweme_id AS "awemeId", 'video'::text AS type, GREATEST( COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0), COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0) ) AS rank, CASE WHEN vt.transcript_zh_tsv @@ tsq.query THEN ts_headline( 'zhcfg', array_to_string(vt.transcript, ' '), tsq.query, 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' ) ELSE ts_headline( 'zhcfg', v.desc, tsq.query, 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' ) END AS snippet, COALESCE(vt.content_tags, ARRAY[]::text[]) AS "contentTags" FROM "Video" v LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id, tsq WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) AND CASE WHEN ${contentTag}::text IS NULL THEN TRUE ELSE vt.content_tags @> ARRAY[${contentTag}]::text[] END UNION ALL -- 图文搜索结果 SELECT 'img_desc:' || ip.aweme_id AS id, ip.aweme_id AS "awemeId", 'image'::text AS type, ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank, ts_headline( 'zhcfg', ip.desc, tsq.query, 'StartSel=,StopSel=,MaxFragments=2,MinWords=2,MaxWords=20' ) AS snippet, ARRAY[]::text[] AS "contentTags" FROM "ImagePost" ip, tsq WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query AND ${contentTag}::text IS NULL ORDER BY rank DESC OFFSET ${offset} LIMIT ${limit}; ` : await prisma.$queryRaw` SELECT vt.id AS id, v.aweme_id AS "awemeId", 'video'::text AS type, 1.0::double precision AS rank, COALESCE( NULLIF(array_to_string(vt.transcript[1:2], ' '), ''), vt.non_speech_summary, v.desc, '标签匹配' ) AS snippet, vt.content_tags AS "contentTags" FROM "VideoTranscript" vt JOIN "Video" v ON v.aweme_id = vt."videoId" WHERE vt.content_tags @> ARRAY[${contentTag}]::text[] ORDER BY v.created_at DESC OFFSET ${offset} LIMIT ${limit}; `; // 查询总数(参数化,避免注入) const totalRows = q ? await prisma.$queryRaw<{ count: number }[]>` WITH tsq AS ( SELECT websearch_to_tsquery('zhcfg', ${q}) AS query ) SELECT ( (SELECT COUNT(*)::int FROM "Video" v LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query) AND CASE WHEN ${contentTag}::text IS NULL THEN TRUE ELSE vt.content_tags @> ARRAY[${contentTag}]::text[] END) + (SELECT COUNT(*)::int FROM "ImagePost" ip WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query AND ${contentTag}::text IS NULL) ) AS count FROM tsq; ` : await prisma.$queryRaw<{ count: number }[]>` SELECT COUNT(*)::int AS count FROM "VideoTranscript" vt WHERE vt.content_tags @> ARRAY[${contentTag}]::text[]; `; // 分离视频和图文ID 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 = videoIds.length > 0 ? ( await 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, }, }) ).map((v) => ({ ...v, cover_url: getFileUrl(v.cover_url || ""), author: { ...v.author, avatar_url: getFileUrl(v.author.avatar_url || ""), }, video_url: getFileUrl(v.video_url || ""), })) : []; // 批量查询图文元信息 const imagePosts = imagePostIds.length > 0 ? ( await 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, }, }, }, }) ).map((ip) => ({ ...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) => ({ ...r, snippet: sanitizeSnippetHtml(r.snippet), video: r.type === "video" ? videos.find((v) => v.aweme_id === r.awemeId) : undefined, imagePost: r.type === "image" ? imagePosts.find((ip) => ip.aweme_id === r.awemeId) : undefined, })), total: totalRows?.[0]?.count ?? 0, page, limit, }); } catch (err) { console.error("Search error:", err); return NextResponse.json({ error: "Search failed" }, { status: 500 }); } }