165 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
)
ELSE
ts_headline(
'zhcfg',
v.desc,
tsq.query,
'StartSel=<mark>,StopSel=</mark>,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=<mark>,StopSel=</mark>,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 });
}
}