import { json } from "@/lib/json"; import { getFileUrl } from "@/lib/minio"; import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例 import { NextResponse } from "next/server"; export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = (searchParams.get("q") || "").trim(); 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 (!q) { 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 = await prisma.$queryRaw< { id: string; awemeId: string; type: "video" | "image"; rank: number; snippet: string; }[] >` 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 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) 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 FROM "ImagePost" ip, tsq WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query ORDER BY rank DESC OFFSET ${offset} LIMIT ${limit}; `; // 查询总数(参数化,避免注入) const totalRows = 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)) + (SELECT COUNT(*)::int FROM "ImagePost" ip WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query) ) AS count FROM tsq; `; // 分离视频和图文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, 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 }); } }