import { db } from "@/lib/db"; import { tool, vote, favorite, review, event, category, toolCategory, } from "@/lib/db/schema"; import { and, desc, eq, gte, gt, sql, inArray } from "drizzle-orm"; import { type RankingBoard, type ToolSignals, trendingScore, bayesianRating, popularScore, } from "./score"; /** 榜单条目的最小类型(完整 tool 行 + 评分上下文) */ export type RankedTool = typeof tool.$inferSelect; /** Trending 默认统计窗口(天) */ export const TRENDING_WINDOW_DAYS = 7; /** Trending 榜单最小展示数量 */ const DEFAULT_LIMIT = 12; /** * # 函数 getTrendingTools * 说明:Trending 榜 —— 窗口期互动加权 ÷ 新鲜度衰减(纯函数计算,便于测试与调参)。 * 参数:limit 数量;windowDays 统计窗口 * 返回值:按 trending 分数降序的工具列表 */ export async function getTrendingTools( limit = DEFAULT_LIMIT, windowDays = TRENDING_WINDOW_DAYS ): Promise { const windowStart = new Date(Date.now() - windowDays * 24 * 3600 * 1000); // 窗口期内的四类行为计数(并行查询) const [voteRows, favoriteRows, reviewRows, viewRows, candidates] = await Promise.all([ db .select({ toolId: vote.toolId, cnt: sql`count(*)` }) .from(vote) .where(gte(vote.createdAt, windowStart)) .groupBy(vote.toolId), db .select({ toolId: favorite.toolId, cnt: sql`count(*)` }) .from(favorite) .where(gte(favorite.createdAt, windowStart)) .groupBy(favorite.toolId), db .select({ toolId: review.toolId, cnt: sql`count(*)` }) .from(review) .where( and(gte(review.createdAt, windowStart), eq(review.status, "published")) ) .groupBy(review.toolId), db .select({ entityId: event.entityId, cnt: sql`count(*)` }) .from(event) .where( and( eq(event.event, "tool_view"), gte(event.createdAt, windowStart), eq(event.entityType, "tool") ) ) .groupBy(event.entityId), db .select() .from(tool) .where(eq(tool.status, "published")) .limit(2000), ]); if (candidates.length === 0) return []; const toMap = ( rows: { toolId?: string | null; entityId?: string | null; cnt: number }[] ) => { const map = new Map(); for (const row of rows) { const key = row.toolId ?? row.entityId; if (key) map.set(key, Number(row.cnt)); } return map; }; const voteMap = toMap(voteRows); const favoriteMap = toMap(favoriteRows); const reviewMap = toMap(reviewRows); const viewMap = toMap(viewRows); const now = Date.now(); const scored = candidates .map((t) => { const signals: ToolSignals = { votesInWindow: voteMap.get(t.id) ?? 0, favoritesInWindow: favoriteMap.get(t.id) ?? 0, reviewsInWindow: reviewMap.get(t.id) ?? 0, viewsInWindow: viewMap.get(t.id) ?? 0, ageHours: (now - new Date(t.createdAt).getTime()) / 3600000, }; return { toolRow: t, score: trendingScore(signals) }; }) .sort((a, b) => b.score - a.score) .slice(0, limit); // 分数为 0 的工具不进入 Trending 榜(避免空榜时全量填充) return scored.filter((s) => s.score > 0).map((s) => s.toolRow); } /** * # 函数 getPopularTools * 说明:Popular 榜 —— 全量互动总分(投票/收藏/评论/浏览加权)。 */ export async function getPopularTools(limit = DEFAULT_LIMIT): Promise { return db .select() .from(tool) .where(eq(tool.status, "published")) .orderBy( desc( sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1` ), desc(tool.createdAt) ) .limit(limit); } /** * # 函数 getMostVotedTools * 说明:Most Voted 榜 —— 按累计投票数排序。 */ export async function getMostVotedTools( limit = DEFAULT_LIMIT ): Promise { return db .select() .from(tool) .where(eq(tool.status, "published")) .orderBy(desc(tool.voteCount), desc(tool.createdAt)) .limit(limit); } /** * # 函数 getMostSavedTools * 说明:Most Saved 榜 —— 按收藏数排序。 */ export async function getMostSavedTools( limit = DEFAULT_LIMIT ): Promise { return db .select() .from(tool) .where(eq(tool.status, "published")) .orderBy(desc(tool.favoriteCount), desc(tool.createdAt)) .limit(limit); } /** * # 函数 getHighestRatedTools * 说明:Highest Rated 榜 —— 贝叶斯平均分排序,评论过少的工具被先验拉回。 */ export async function getHighestRatedTools( limit = DEFAULT_LIMIT ): Promise { const [candidates, globalRows] = await Promise.all([ db .select() .from(tool) .where(and(eq(tool.status, "published"), gt(tool.reviewCount, 0))) .limit(1000), db .select({ avg: sql`COALESCE(AVG(${tool.ratingAvg}), 0)`, }) .from(tool) .where(and(eq(tool.status, "published"), gt(tool.reviewCount, 0))), ]); const globalAvg = Number(globalRows[0]?.avg ?? 0); if (candidates.length === 0) return []; return candidates .map((t) => ({ toolRow: t, score: bayesianRating(t.ratingAvg, t.reviewCount, globalAvg), })) .sort((a, b) => b.score - a.score) .slice(0, limit) .map((s) => s.toolRow); } /** * # 函数 getNewTools * 说明:New 榜 —— 最新发布。 */ export async function getNewTools(limit = DEFAULT_LIMIT): Promise { return db .select() .from(tool) .where(eq(tool.status, "published")) .orderBy(desc(tool.createdAt)) .limit(limit); } /** * # 函数 getEditorsChoiceTools * 说明:Editor's Choice 榜 —— 管理员 featured 标记。 */ export async function getEditorsChoiceTools( limit = DEFAULT_LIMIT ): Promise { return db .select() .from(tool) .where(and(eq(tool.status, "published"), eq(tool.featured, true))) .orderBy(desc(tool.ratingAvg), desc(tool.createdAt)) .limit(limit); } /** * # 函数 getRankedTools * 说明:统一榜单入口(Ranking Engine 对外接口)。 * 未来可在此挂接站点级 Ranking 策略(Product Hunt 式 / 编辑式等)。 * 参数:board 榜单标识;limit 数量 * 返回值:榜单工具列表 */ export async function getRankedTools( board: RankingBoard, limit = DEFAULT_LIMIT ): Promise { switch (board) { case "trending": return getTrendingTools(limit); case "popular": return getPopularTools(limit); case "most-voted": return getMostVotedTools(limit); case "most-saved": return getMostSavedTools(limit); case "highest-rated": return getHighestRatedTools(limit); case "new": return getNewTools(limit); case "editors-choice": return getEditorsChoiceTools(limit); } } /** 全站 Popular 分数排序键(SQL 片段,供列表页复用) */ export function popularScoreSql() { return sql`${tool.voteCount} * 2 + ${tool.favoriteCount} * 1.5 + ${tool.reviewCount} * 3 + ${tool.viewCount} * 0.1`; } // popularScore 在 JS 侧的引用(供测试对齐 SQL 权重) export const POPULAR_WEIGHTS = popularScore;