Initial WhaleTown V2 backend

This commit is contained in:
2026-07-20 02:00:52 +08:00
commit c995891c1f
265 changed files with 75689 additions and 0 deletions

View File

@@ -0,0 +1,436 @@
import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import {
DatawhaleCommitStats,
DatawhaleHonorRankingPayload,
DatawhaleMemberRow,
DatawhaleWeeklyCommitsPayload,
RankingCategory,
RankingCategoryId,
RankingEntry,
RankingUser,
} from './rankings.types';
const DATAWHALE_MEMBERS_URL = 'https://mv.datawhale.cc/data/members.json';
const DATAWHALE_WEEKLY_COMMITS_URL = 'https://mv.datawhale.cc/data/commits_weekly.json';
const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/';
const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits';
const DEFAULT_LIMIT = 10;
const CATEGORIES: RankingCategory[] = [
{
id: 'weekly_commits',
label: '一周卷王',
title: '一周卷王',
description: '近 7 天 commit 数 + 连续性、多仓库、质量奖励',
icon: '🔥',
},
{
id: 'night_owl',
label: '夜猫榜',
title: '夜猫榜',
description: '深夜提交数量与深夜活跃比例排行',
icon: '🌙',
},
{
id: 'popularity',
label: '人气王',
title: '人气王',
description: 'Followers 与组织仓库 Stars 的综合影响力',
icon: '👑',
},
{
id: 'productive',
label: '多产榜',
title: '多产榜',
description: '参与 Datawhale 组织仓库数量排行',
icon: '🏆',
},
{
id: 'social',
label: '社交达人',
title: '社交达人',
description: 'GitHub Following 数量排行',
icon: '💬',
},
{
id: 'rising',
label: '新星榜',
title: '新星榜',
description: '按仓库数量归一后的潜力新星排行',
icon: '🌠',
},
{
id: 'comprehensive',
label: '综合实力',
title: '综合实力',
description: 'Stars、Followers、仓库数、社交和贡献数综合评分',
icon: '🌟',
},
];
@Injectable()
export class RankingsService implements OnModuleInit {
private readonly logger = new Logger(RankingsService.name);
private members: DatawhaleMemberRow[] = [];
private weeklyCommits: DatawhaleWeeklyCommitsPayload | null = null;
private syncedAt: Date | null = null;
private syncing: Promise<void> | null = null;
async onModuleInit(): Promise<void> {
this.syncNow().catch(error => {
this.logger.warn(`Datawhale荣誉榜启动同步失败${this.errorMessage(error)}`);
});
}
@Cron('15 3 * * *')
async syncDaily(): Promise<void> {
await this.syncNow();
}
async getDatawhaleHonorRanking(
category: RankingCategoryId = DEFAULT_CATEGORY,
limit: number = DEFAULT_LIMIT,
refresh = false,
): Promise<DatawhaleHonorRankingPayload> {
if (refresh || this.members.length === 0) {
await this.syncNow();
}
return this.getCachedPayload(category, limit);
}
async syncNow(): Promise<DatawhaleHonorRankingPayload> {
if (this.syncing) {
await this.syncing;
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
}
this.syncing = this.fetchAndReplace();
try {
await this.syncing;
} finally {
this.syncing = null;
}
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
}
private getCachedPayload(
category: RankingCategoryId,
limit: number,
): DatawhaleHonorRankingPayload {
const normalizedCategory = this.normalizeCategory(category);
const normalizedLimit = this.normalizeLimit(limit);
const entries = this.buildEntries(normalizedCategory, normalizedLimit);
return {
source: 'datawhale-members-visualization',
activeCategory: normalizedCategory,
categories: CATEGORIES,
topRankers: entries.slice(0, 3),
rankers: entries.slice(3, normalizedLimit),
myRank: {
rank: null,
score: 0,
reward: 0,
},
total: entries.length,
syncedAt: this.syncedAt ? this.syncedAt.toISOString() : null,
sourceUpdatedAt: this.weeklyCommits?.update_time ?? null,
};
}
private async fetchAndReplace(): Promise<void> {
try {
const [membersResponse, commitsResponse] = await Promise.all([
axios.get(DATAWHALE_MEMBERS_URL, { timeout: 15000 }),
axios.get(DATAWHALE_WEEKLY_COMMITS_URL, { timeout: 15000 }),
]);
if (!Array.isArray(membersResponse.data)) {
throw new BadGatewayException('Datawhale成员接口返回格式异常');
}
const commitsPayload = commitsResponse.data as DatawhaleWeeklyCommitsPayload;
if (!commitsPayload || typeof commitsPayload !== 'object' || !commitsPayload.user_commits) {
throw new BadGatewayException('Datawhale周贡献接口返回格式异常');
}
this.members = membersResponse.data as DatawhaleMemberRow[];
this.weeklyCommits = commitsPayload;
this.syncedAt = new Date();
this.logger.log(`Datawhale荣誉榜同步完成${this.members.length} 位成员`);
} catch (error) {
if (this.members.length > 0) {
this.logger.warn(`Datawhale荣誉榜同步失败继续使用缓存${this.errorMessage(error)}`);
return;
}
throw error;
}
}
private buildEntries(category: RankingCategoryId, limit: number): RankingEntry[] {
const users = category === 'weekly_commits' || category === 'night_owl'
? this.buildCommitDrivenUsers(category)
: this.members.map(member => this.toRankingUser(member, category));
return users
.filter(user => user.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((user, index) => ({
...user,
rank: index + 1,
}));
}
private buildCommitDrivenUsers(category: RankingCategoryId): RankingUser[] {
const memberById = new Map(
this.members
.map(member => [this.cleanString(member.id), member] as const)
.filter(([id]) => id.length > 0),
);
const userCommits = this.weeklyCommits?.user_commits ?? {};
return Object.entries(userCommits).map(([rawId, commits]) => {
const id = this.cleanString(rawId);
const member = memberById.get(id) ?? { id };
return this.toRankingUser(member, category, commits);
});
}
private toRankingUser(
member: DatawhaleMemberRow,
category: RankingCategoryId,
commitsOverride?: DatawhaleCommitStats,
): RankingUser {
const id = this.cleanString(member.id);
const commits = commitsOverride ?? this.weeklyCommits?.user_commits?.[id] ?? {};
const score = this.scoreMember(member, commits, category);
const domains = this.domainList(member);
return {
id,
name: this.displayName(member),
avatarText: this.avatarText(member),
avatarUrl: this.avatarUrl(member),
githubUrl: this.githubUrl(member),
domain: domains[0] ?? '',
domains,
location: this.cleanString(member.location),
score,
scoreLabel: this.scoreLabel(member, commits, category),
contrib: this.numberValue(member.org_total_contributions) || this.numberValue(commits.total_commits),
answers: this.numberValue(commits.repo_count) || this.numberValue(member.org_repos_count),
likes: this.numberValue(member.org_total_stars),
reward: this.rewardForScore(score),
};
}
private scoreMember(
member: DatawhaleMemberRow,
commits: DatawhaleCommitStats,
category: RankingCategoryId,
): number {
switch (category) {
case 'weekly_commits':
return this.weeklyCommitScore(commits);
case 'night_owl':
return this.nightOwlScore(commits);
case 'popularity':
return Math.round(
this.numberValue(member.followers ?? member.followers_count) * 0.6 +
this.numberValue(member.org_total_stars) * 0.4,
);
case 'productive':
return this.numberValue(member.org_repos_count);
case 'social':
return this.numberValue(member.following);
case 'rising': {
const repoCount = Math.max(this.numberValue(member.org_repos_count), 1);
const bonus = repoCount < 5 ? 1.5 : 1.0;
return Math.round((this.numberValue(member.followers ?? member.followers_count) + this.numberValue(member.org_total_stars)) / repoCount * bonus);
}
case 'comprehensive':
return Math.round(
this.numberValue(member.org_total_stars) * 0.3 +
this.numberValue(member.followers ?? member.followers_count) * 0.25 +
this.numberValue(member.org_repos_count) * 0.2 +
this.numberValue(member.following) * 0.15 +
this.numberValue(member.org_total_contributions) * 0.1,
);
default:
return 0;
}
}
private weeklyCommitScore(commits: DatawhaleCommitStats): number {
let score = this.numberValue(commits.total_commits);
const activeDays = this.numberValue(commits.active_days);
if (activeDays >= 7) {
score += 10;
} else if (activeDays >= 5) {
score += 5;
} else if (activeDays >= 3) {
score += 2;
}
const repoCount = this.numberValue(commits.repo_count);
if (repoCount >= 5) {
score += 5;
} else if (repoCount >= 3) {
score += 3;
} else if (repoCount >= 2) {
score += 1;
}
const avgCommitsPerDay = this.numberValue(commits.avg_commits_per_day);
if (avgCommitsPerDay >= 5) {
score += 8;
} else if (avgCommitsPerDay >= 3) {
score += 5;
} else if (avgCommitsPerDay >= 2) {
score += 2;
}
return Math.round(score);
}
private nightOwlScore(commits: DatawhaleCommitStats): number {
let score = this.numberValue(commits.night_owl_commits) * 2;
const percentage = this.numberValue(commits.night_owl_percentage);
if (percentage >= 50) {
score += 10;
} else if (percentage >= 30) {
score += 5;
} else if (percentage >= 20) {
score += 2;
}
const activeDays = this.numberValue(commits.active_days);
if (activeDays >= 5) {
score += 8;
} else if (activeDays >= 3) {
score += 4;
}
const repoCount = this.numberValue(commits.repo_count);
if (repoCount >= 3) {
score += 3;
} else if (repoCount >= 2) {
score += 1;
}
return Math.round(score);
}
private scoreLabel(
member: DatawhaleMemberRow,
commits: DatawhaleCommitStats,
category: RankingCategoryId,
): string {
switch (category) {
case 'weekly_commits':
return `${this.numberValue(commits.total_commits)} commits`;
case 'night_owl':
return `${this.numberValue(commits.night_owl_commits)} 深夜`;
case 'popularity':
return `${this.numberValue(member.followers ?? member.followers_count)} followers`;
case 'productive':
return `${this.numberValue(member.org_repos_count)} 仓库`;
case 'social':
return `${this.numberValue(member.following)} following`;
case 'rising':
return '活跃度';
case 'comprehensive':
return '综合分';
default:
return '分数';
}
}
private domainList(member: DatawhaleMemberRow): string[] {
const primaryDomain = this.cleanString(member.primary_domain);
const domains = this.cleanString(member.domain)
.split(';')
.map(domain => this.cleanString(domain))
.filter(Boolean);
const result = primaryDomain ? [primaryDomain, ...domains] : domains;
return [...new Set(result)].slice(0, 3);
}
private displayName(member: DatawhaleMemberRow): string {
const name = this.cleanString(member.name);
if (name && !['null', 'undefined', 'none'].includes(name.toLowerCase())) {
return name;
}
return this.cleanString(member.id) || '未知用户';
}
private avatarText(member: DatawhaleMemberRow): string {
const name = this.displayName(member);
return name.length > 0 ? name.slice(0, 1).toUpperCase() : '鲸';
}
private avatarUrl(member: DatawhaleMemberRow): string {
const avatar = this.cleanString(member.avatar);
if (!avatar) {
return '';
}
if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
return avatar;
}
if (avatar.startsWith('/')) {
return `${DATAWHALE_ASSET_BASE_URL.replace(/\/$/, '')}${avatar}`;
}
return `${DATAWHALE_ASSET_BASE_URL}${avatar}`;
}
private githubUrl(member: DatawhaleMemberRow): string {
const github = this.cleanString(member.github);
if (github.startsWith('http')) {
return github;
}
const id = this.cleanString(member.id);
return id ? `https://github.com/${id}` : '';
}
private normalizeCategory(category: RankingCategoryId): RankingCategoryId {
return CATEGORIES.some(item => item.id === category) ? category : DEFAULT_CATEGORY;
}
private normalizeLimit(limit: number): number {
if (!Number.isFinite(limit)) {
return DEFAULT_LIMIT;
}
return Math.min(Math.max(Math.floor(limit), 3), DEFAULT_LIMIT);
}
private rewardForScore(score: number): number {
if (score >= 1000) {
return 80;
}
if (score >= 500) {
return 60;
}
if (score >= 120) {
return 50;
}
if (score >= 50) {
return 40;
}
if (score >= 20) {
return 30;
}
return 20;
}
private numberValue(value: unknown): number {
const parsed = Number(value || 0);
return Number.isFinite(parsed) ? parsed : 0;
}
private cleanString(value: unknown): string {
return String(value ?? '').trim();
}
private errorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}