- 播放上下文:作品链接带上来源(搜索、标签、作者、hashtag),上一条/下一条 在该列表内移动;当前作品不在列表中时退回全部作品的发布时间顺序 - 顶部提示标签显示当前列表与位置,点击打开侧栏列表,✕ 退出 - 侧栏改为「评论 | TA的作品 | 搜索结果/#hashtag」标签页,作品封面网格可就地切换 - 作者模式:打开「TA的作品」进入,关闭侧栏不退出,点提示 ✕ 后回到原来的列表 - 从搜索结果进入或点 hashtag 时自动打开对应列表 - 描述中的 hashtag 可点击,按完整 hashtag 精确匹配(#丰川祥子 不含 #丰川祥子cos) - 新增 /api/playlist;搜索 SQL 抽到 lib/search-query.ts 与播放页共用,保证顺序一致 - 移动端:底部抽屉、点作品后收起,媒体区支持上下滑切换 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
116 lines
3.7 KiB
TypeScript
116 lines
3.7 KiB
TypeScript
// 搜索的 SQL 部分:搜索接口(分页取结果)和播放页(取完整的有序 ID 列表算上一条/下一条)
|
|
// 共用同一段匹配与排序逻辑,保证两边顺序完全一致。
|
|
import { Prisma } from "@prisma/client";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { termToPgPattern } from "@/lib/search";
|
|
|
|
export type SearchInput = {
|
|
/** 已转小写的关键词(parseSearchQuery 的结果) */
|
|
terms: string[];
|
|
excludes: string[];
|
|
contentTag: string | null;
|
|
};
|
|
|
|
export type SearchRow = {
|
|
awemeId: string;
|
|
type: "video" | "image";
|
|
score: number;
|
|
inDesc: boolean;
|
|
inTranscript: boolean;
|
|
rawDesc: string;
|
|
rawTranscript: string;
|
|
contentTags: string[];
|
|
total: number;
|
|
};
|
|
|
|
// 子串匹配:每个关键词都必须出现在「描述 + 语音转写」里(不区分大小写),
|
|
// 不依赖分词,所以输入任意片段都能搜到。数据量小,全表扫描足够快。
|
|
// 打分:描述命中权重更高,按出现次数累加(设上限防止长转写刷分);同分按发布时间倒序。
|
|
// 没有关键词、只按标签筛选时,score 都是 0,等价于按时间排序。
|
|
function matchedCte({ terms, excludes, contentTag }: SearchInput) {
|
|
const patterns = terms.map(termToPgPattern);
|
|
const excludePatterns = excludes.map(termToPgPattern);
|
|
return Prisma.sql`
|
|
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
|
|
)
|
|
)
|
|
`;
|
|
}
|
|
|
|
const ORDER_BY = Prisma.sql`ORDER BY score DESC, created DESC, "awemeId"`;
|
|
|
|
export function searchRows(input: SearchInput, offset: number, limit: number) {
|
|
return prisma.$queryRaw<SearchRow[]>`
|
|
${matchedCte(input)}
|
|
SELECT
|
|
"awemeId", type, score, "inDesc", "inTranscript",
|
|
"rawDesc", "rawTranscript", "contentTags",
|
|
(COUNT(*) OVER ())::int AS total
|
|
FROM matched
|
|
${ORDER_BY}
|
|
OFFSET ${offset}
|
|
LIMIT ${limit};
|
|
`;
|
|
}
|
|
|
|
/** 按搜索结果顺序返回全部命中作品的 ID。 */
|
|
export async function searchIds(input: SearchInput): Promise<string[]> {
|
|
const rows = await prisma.$queryRaw<{ awemeId: string }[]>`
|
|
${matchedCte(input)}
|
|
SELECT "awemeId" FROM matched
|
|
${ORDER_BY};
|
|
`;
|
|
return rows.map((r) => r.awemeId);
|
|
}
|