176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { Prisma } from "@prisma/client";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
export async function GET(
|
|
request: NextRequest,
|
|
{ params }: { params: Promise<{ awemeId: string }> }
|
|
) {
|
|
const awemeId = (await params).awemeId;
|
|
const searchParams = request.nextUrl.searchParams;
|
|
const skip = parseInt(searchParams.get("skip") || "0", 10);
|
|
const take = parseInt(searchParams.get("take") || "20", 10);
|
|
const mode = (searchParams.get("mode") || "").toLowerCase();
|
|
|
|
// ranked 模式参数(均为可选,提供合理默认值)
|
|
const seed = searchParams.get("seed") || new Date().toISOString().slice(0, 10); // 默认按日期稳定
|
|
const snapshotIso = searchParams.get("snapshot");
|
|
const snapshot = snapshotIso ? new Date(snapshotIso) : new Date(); // 用于时间衰减的基准时间,确保单次会话稳定
|
|
const halfLifeHours = parseFloat(searchParams.get("halfLifeHours") || "24");
|
|
const wPop = parseFloat(searchParams.get("wPop") || "1"); // 热度权重
|
|
const wTime = parseFloat(searchParams.get("wTime") || "2"); // 时间衰减权重
|
|
const wJit = parseFloat(searchParams.get("wJit") || "10"); // 随机扰动权重(稳定随机)
|
|
|
|
try {
|
|
// 查找是视频还是图文
|
|
const [video, post] = await Promise.all([
|
|
prisma.video.findUnique({
|
|
where: { aweme_id: awemeId },
|
|
select: { aweme_id: true },
|
|
}),
|
|
prisma.imagePost.findUnique({
|
|
where: { aweme_id: awemeId },
|
|
select: { aweme_id: true },
|
|
}),
|
|
]);
|
|
|
|
if (!video && !post) {
|
|
return NextResponse.json({ error: "作品不存在" }, { status: 404 });
|
|
}
|
|
|
|
// 构建查询条件
|
|
const where = video
|
|
? { videoId: awemeId }
|
|
: { imagePostId: awemeId };
|
|
|
|
// ranked 模式:按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
|
|
if (mode === "ranked") {
|
|
const total = await prisma.comment.count({ where });
|
|
|
|
// 计算稳定随机扰动:基于 (cid, seed) 的 md5 -> 取前4字节 -> 映射到 [0,1)
|
|
const jitterExpr = Prisma.sql`(
|
|
(
|
|
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 0)::double precision * 16777216.0 +
|
|
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 1)::double precision * 65536.0 +
|
|
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 2)::double precision * 256.0 +
|
|
get_byte(decode(md5(c."cid" || ':' || ${seed}), 'hex'), 3)::double precision
|
|
) / 4294967295.0
|
|
)`;
|
|
|
|
const snapshotTs = snapshot.toISOString();
|
|
|
|
// popularity: ln(1 + digg_count)
|
|
// time decay: exp(- age_hours / halfLifeHours),使用固定 snapshot 基准保证会话内稳定
|
|
const scoreExpr = Prisma.sql`(
|
|
${wPop} * ln(1 + c."digg_count"::numeric) +
|
|
${wTime} * exp(- (extract(epoch from (${snapshotTs}::timestamptz - c."created_at")) / 3600.0) / ${halfLifeHours}) +
|
|
${wJit} * ${jitterExpr}
|
|
)`;
|
|
|
|
const whereField = (await video) ? Prisma.sql`c."videoId"` : Prisma.sql`c."imagePostId"`;
|
|
|
|
const rows: Array<{
|
|
cid: string;
|
|
text: string;
|
|
created_at: Date;
|
|
digg_count: bigint;
|
|
nickname: string;
|
|
avatar_url: string | null;
|
|
}> = await prisma.$queryRaw(
|
|
Prisma.sql`
|
|
SELECT
|
|
c."cid",
|
|
c."text",
|
|
c."created_at",
|
|
c."digg_count",
|
|
u."nickname",
|
|
u."avatar_url"
|
|
FROM "Comment" c
|
|
JOIN "CommentUser" u ON u."id" = c."userId"
|
|
WHERE ${whereField} = ${awemeId}
|
|
ORDER BY ${scoreExpr} DESC, c."created_at" DESC, c."cid" ASC
|
|
OFFSET ${skip}
|
|
LIMIT ${take}
|
|
`
|
|
);
|
|
|
|
// 批量查询每条评论的配图/贴纸
|
|
const cids = rows.map(r => r.cid);
|
|
const images = cids.length
|
|
? await prisma.commentImage.findMany({
|
|
where: { commentId: { in: cids } },
|
|
orderBy: { order: 'asc' },
|
|
select: { commentId: true, url: true, width: true, height: true, order: true },
|
|
})
|
|
: [];
|
|
const group = new Map<string, { url: string; width?: number | null; height?: number | null; order: number }[]>();
|
|
for (const img of images) {
|
|
const arr = group.get(img.commentId) || [];
|
|
arr.push({ url: img.url, width: img.width, height: img.height, order: img.order });
|
|
group.set(img.commentId, arr);
|
|
}
|
|
const formattedComments = rows.map((c) => ({
|
|
cid: c.cid,
|
|
text: c.text,
|
|
created_at: c.created_at,
|
|
digg_count: Number(c.digg_count),
|
|
user: {
|
|
nickname: c.nickname,
|
|
avatar_url: c.avatar_url || undefined,
|
|
},
|
|
images: (group.get(c.cid) || []).sort((a,b)=>a.order-b.order).map(i=>({ url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
|
}));
|
|
|
|
return NextResponse.json({
|
|
comments: formattedComments,
|
|
total,
|
|
hasMore: skip + take < total,
|
|
// 回传用于稳定排序的参数,前端可在后续请求中透传
|
|
seed,
|
|
snapshot: snapshotTs,
|
|
weights: { wPop, wTime, wJit, halfLifeHours },
|
|
mode: "ranked",
|
|
});
|
|
}
|
|
|
|
// 默认模式:获取评论总数和分页数据(使用稳定且不可变的排序,避免因 digg_count 变化导致翻页重复/遗漏)
|
|
const [total, comments] = await Promise.all([
|
|
prisma.comment.count({ where }),
|
|
prisma.comment.findMany({
|
|
where,
|
|
orderBy: [
|
|
{ created_at: "desc" },
|
|
{ cid: "asc" },
|
|
],
|
|
include: { user: true, images: { orderBy: { order: 'asc' }, select: { url: true, width: true, height: true } } },
|
|
skip,
|
|
take,
|
|
}),
|
|
]);
|
|
|
|
const formattedComments = comments.map((c) => ({
|
|
cid: c.cid,
|
|
text: c.text,
|
|
created_at: c.created_at,
|
|
digg_count: Number(c.digg_count),
|
|
user: {
|
|
nickname: c.user.nickname,
|
|
avatar_url: c.user.avatar_url,
|
|
},
|
|
images: (c.images || []).map(i=>({ url: i.url, width: i.width ?? undefined, height: i.height ?? undefined })),
|
|
}));
|
|
|
|
return NextResponse.json({
|
|
comments: formattedComments,
|
|
total,
|
|
hasMore: skip + take < total,
|
|
});
|
|
} catch (error) {
|
|
console.error("获取评论失败:", error);
|
|
return NextResponse.json(
|
|
{ error: "获取评论失败" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|