feat: AI Directory Platform 底座升级 + 五套主题站点
- 数据模型: 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事件
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user