143 lines
5.2 KiB
TypeScript

import { getFileUrl } from "@/lib/minio";
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);
// 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 };
// 按「热度 + 时间衰减 + 稳定随机扰动」打分排序;否则使用稳定的时间排序
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: getFileUrl(c.avatar_url || 'default_avatar.png'),
},
images: (group.get(c.cid) || []).sort((a, b) => a.order - b.order).map(i => ({ url: getFileUrl(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",
});
} catch (error) {
console.error("获取评论失败:", error);
return NextResponse.json(
{ error: "获取评论失败" },
{ status: 500 }
);
}
}