fix: 合并服务器未推送改动并保留手动视频标签
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { GET } from "../../stt/videos/route";
|
||||
@@ -31,6 +31,7 @@ export async function GET() {
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
content_tags: v.manual_content_tags,
|
||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "../stt/route";
|
||||
@@ -0,0 +1,121 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type UpdateMode = "set" | "add";
|
||||
|
||||
function uniqueValidTags(tags: unknown[]) {
|
||||
const validTags: string[] = [];
|
||||
for (const tag of tags) {
|
||||
if (typeof tag !== "string" || !isVideoContentTag(tag)) {
|
||||
throw new Error("Invalid tag");
|
||||
}
|
||||
if (!validTags.includes(tag)) validTags.push(tag);
|
||||
}
|
||||
return validTags;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = body as {
|
||||
awemeId?: unknown;
|
||||
awemeIds?: unknown;
|
||||
tag?: unknown;
|
||||
contentTags?: unknown;
|
||||
mode?: unknown;
|
||||
};
|
||||
const awemeIds = Array.isArray(raw.awemeIds)
|
||||
? raw.awemeIds
|
||||
: typeof raw.awemeId === "string"
|
||||
? [raw.awemeId]
|
||||
: [];
|
||||
const normalizedAwemeIds = [...new Set(awemeIds)]
|
||||
.filter(
|
||||
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||
)
|
||||
.map((id) => id.trim());
|
||||
|
||||
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "awemeIds must contain 1 to 200 ids" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
|
||||
const rawTags = Array.isArray(raw.contentTags)
|
||||
? raw.contentTags
|
||||
: typeof raw.tag === "string"
|
||||
? [raw.tag]
|
||||
: [];
|
||||
|
||||
let contentTags: string[];
|
||||
try {
|
||||
contentTags = uniqueValidTags(rawTags);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (mode === "add" && contentTags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "add mode requires at least one tag" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const videos = await prisma.video.findMany({
|
||||
where: { aweme_id: { in: normalizedAwemeIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
manual_content_tags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (videos.length === 0) {
|
||||
return NextResponse.json({ error: "No videos found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = videos.map((video) => {
|
||||
const baseTags = video.manual_content_tags;
|
||||
const manualTags =
|
||||
mode === "add"
|
||||
? [...new Set([...baseTags, ...contentTags])]
|
||||
: contentTags;
|
||||
return { awemeId: video.aweme_id, manualTags };
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
updates.map((update) =>
|
||||
prisma.video.update({
|
||||
where: { aweme_id: update.awemeId },
|
||||
data: { manual_content_tags: update.manualTags },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
updated: updates.map((update) => ({
|
||||
aweme_id: update.awemeId,
|
||||
manual_content_tags: update.manualTags,
|
||||
content_tags: update.manualTags,
|
||||
})),
|
||||
missing: normalizedAwemeIds.filter(
|
||||
(id) => !videos.some((video) => video.aweme_id === id),
|
||||
),
|
||||
});
|
||||
}
|
||||
+52
-26
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
// Contract
|
||||
// Inputs: search params { before?: ISOString, limit?: number }
|
||||
@@ -11,24 +12,45 @@ export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const limitParam = searchParams.get("limit");
|
||||
const beforeParam = searchParams.get("before");
|
||||
const rawTag = (searchParams.get("tag") || "").trim();
|
||||
const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
|
||||
if (rawTag && !selectedTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||
const before = beforeParam ? new Date(beforeParam) : null;
|
||||
const videoWhere = {
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
...(selectedTag
|
||||
? {
|
||||
manual_content_tags: { has: selectedTag },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// fetch chunk from both tables
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
where: videoWhere,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true },
|
||||
}),
|
||||
prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
||||
include: {
|
||||
author: true,
|
||||
},
|
||||
}),
|
||||
selectedTag
|
||||
? Promise.resolve([])
|
||||
: prisma.imagePost.findMany({
|
||||
where: before ? { created_at: { lt: before } } : undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: limit,
|
||||
include: {
|
||||
author: true,
|
||||
images: { orderBy: { order: "asc" }, take: 1 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const merged: FeedItem[] = [
|
||||
@@ -47,29 +69,33 @@ export async function GET(req: NextRequest) {
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.manual_content_tags,
|
||||
})),
|
||||
...posts.map((p) => ({
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
return {
|
||||
type: "image" as const,
|
||||
aweme_id: p.aweme_id,
|
||||
created_at: p.created_at,
|
||||
desc: p.desc,
|
||||
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||
width: p.images?.[0]?.width ?? null,
|
||||
height: p.images?.[0]?.height ?? null,
|
||||
author: {
|
||||
nickname: p.author.nickname,
|
||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||
sec_uid: p.author.sec_uid,
|
||||
},
|
||||
likes: Number(p.digg_count),
|
||||
};
|
||||
}),
|
||||
]
|
||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||
.slice(0, limit);
|
||||
|
||||
const nextCursor =
|
||||
merged.length > 0
|
||||
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
||||
? new Date(merged[merged.length - 1].created_at).toISOString()
|
||||
: null;
|
||||
const payload: FeedResponse = { items: merged, nextCursor };
|
||||
return NextResponse.json(payload);
|
||||
|
||||
+133
-80
@@ -1,11 +1,32 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isVideoContentTag } from "@/lib/video-tags";
|
||||
import { json } from "@/lib/json";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
|
||||
import { NextResponse } from "next/server";
|
||||
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>", "<mark>")
|
||||
.replaceAll("</mark>", "</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 && isVideoContentTag(rawTag) ? rawTag : null;
|
||||
const page = Math.max(1, Number(searchParams.get("page") || 1));
|
||||
const limit = Math.min(
|
||||
50,
|
||||
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
if (!q) {
|
||||
if (rawTag && !contentTag) {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!q && !contentTag) {
|
||||
return NextResponse.json({ results: [], total: 0 });
|
||||
}
|
||||
|
||||
@@ -23,90 +48,117 @@ export async function GET(req: Request) {
|
||||
// - 合并搜索来源: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
|
||||
const rows = q
|
||||
? await prisma.$queryRaw<SearchRow[]>`
|
||||
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,
|
||||
v.manual_content_tags 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 v.manual_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',
|
||||
array_to_string(vt.transcript, ' '),
|
||||
ip.desc,
|
||||
tsq.query,
|
||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||
)
|
||||
ELSE
|
||||
ts_headline(
|
||||
'zhcfg',
|
||||
) 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<SearchRow[]>`
|
||||
SELECT
|
||||
COALESCE(vt.id, 'tag:' || v.aweme_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], ' '), ''),
|
||||
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};
|
||||
`;
|
||||
'标签匹配'
|
||||
) AS snippet,
|
||||
v.manual_content_tags AS "contentTags"
|
||||
FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ORDER BY v.created_at 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;
|
||||
`;
|
||||
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 v.manual_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 "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[];
|
||||
`;
|
||||
|
||||
// 分离视频和图文ID
|
||||
const videoIds = rows
|
||||
@@ -176,6 +228,7 @@ export async function GET(req: Request) {
|
||||
return json({
|
||||
results: rows.map((r) => ({
|
||||
...r,
|
||||
snippet: sanitizeSnippetHtml(r.snippet),
|
||||
video:
|
||||
r.type === "video"
|
||||
? videos.find((v) => v.aweme_id === r.awemeId)
|
||||
|
||||
Reference in New Issue
Block a user