- 数据模型: vote/review/submission/claim/use_case/audience/collection/event (+tool 冗余统计列) - 社区: 投票(限流+原子toggle+API) / 评论(评分+优缺点+Helpful+聚合回写) / 提交(重复检测+审批发布) / 认领(Owner白名单编辑) - Ranking Engine: trending/popular/most-voted/most-saved/highest-rated/new/editors-choice (纯函数可测) - 发现体系: Use Case + Audience + Collection 三维发现 / /ai-tools-for / /best SEO 页 - Programmatic SEO: 页面生成器 / sitemap 270条 / AggregateRating/FAQPage/Breadcrumb/ItemList schema / alternatives + vs 对比页 - Analytics: event 事件流 + 外链跳转埋点 + /admin/analytics CTR 与搜索词分析 - Admin: submissions/claims/reviews/discovery/vote-audit/analytics 六个新后台 + 仪表盘扩展 - 主题系统: themes registry + 五套站点 (classic/product/editorial/niche/minimal) 构建期注入 - 认证: GitHub OAuth (env门控) + 登录回跳 redirect - i18n: 新增 7 个命名空间中英文案 (546 键一致) - 测试: bun test 32 用例 (ranking/slug/去重/SEO生成器) + Playwright e2e 脚本 - 种子: 14分类/16用例/9受众/40工具/6用户/投票/评论/6合集/7718事件
262 lines
7.3 KiB
TypeScript
262 lines
7.3 KiB
TypeScript
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<RankedTool[]> {
|
|
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<number>`count(*)` })
|
|
.from(vote)
|
|
.where(gte(vote.createdAt, windowStart))
|
|
.groupBy(vote.toolId),
|
|
db
|
|
.select({ toolId: favorite.toolId, cnt: sql<number>`count(*)` })
|
|
.from(favorite)
|
|
.where(gte(favorite.createdAt, windowStart))
|
|
.groupBy(favorite.toolId),
|
|
db
|
|
.select({ toolId: review.toolId, cnt: sql<number>`count(*)` })
|
|
.from(review)
|
|
.where(
|
|
and(gte(review.createdAt, windowStart), eq(review.status, "published"))
|
|
)
|
|
.groupBy(review.toolId),
|
|
db
|
|
.select({ entityId: event.entityId, cnt: sql<number>`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<string, number>();
|
|
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<RankedTool[]> {
|
|
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<RankedTool[]> {
|
|
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<RankedTool[]> {
|
|
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<RankedTool[]> {
|
|
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<number>`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<RankedTool[]> {
|
|
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<RankedTool[]> {
|
|
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<RankedTool[]> {
|
|
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;
|