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:
AI Directory Platform
2026-09-08 17:38:06 +00:00
parent 41d9fcac06
commit e46b4b52a3
90 changed files with 13642 additions and 7940 deletions
+261
View File
@@ -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;