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 }); } }