forked from xiangwang25/whale-town-end-v2
Initial WhaleTown V2 backend
This commit is contained in:
61
src/business/rankings/rankings.controller.ts
Normal file
61
src/business/rankings/rankings.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { RankingsService } from './rankings.service';
|
||||
import { RankingCategoryId } from './rankings.types';
|
||||
|
||||
@ApiTags('rankings')
|
||||
@Controller('rankings')
|
||||
export class RankingsController {
|
||||
constructor(private readonly rankingsService: RankingsService) {}
|
||||
|
||||
@Get('datawhale-honor')
|
||||
@ApiOperation({
|
||||
summary: '获取Datawhale荣誉榜',
|
||||
description: '返回后端同步并计算后的Datawhale贡献者排行榜数据,供游戏荣誉榜UI使用。',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'category',
|
||||
required: false,
|
||||
description: '榜单分类:weekly_commits/night_owl/popularity/productive/social/rising/comprehensive',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: '返回数量,范围 3-10,默认 10',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'refresh',
|
||||
required: false,
|
||||
description: '为 true 时先实时同步 Datawhale 公开数据,再返回当前榜单',
|
||||
})
|
||||
async getDatawhaleHonorRanking(
|
||||
@Query('category') category?: RankingCategoryId,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
) {
|
||||
const data = await this.rankingsService.getDatawhaleHonorRanking(
|
||||
category,
|
||||
Number(limit || 10),
|
||||
refresh === 'true',
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '荣誉榜获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('datawhale-honor/sync')
|
||||
@ApiOperation({
|
||||
summary: '手动同步Datawhale荣誉榜',
|
||||
description: '开发调试用:立即从Datawhale公开数据源同步并返回默认榜单。',
|
||||
})
|
||||
async syncDatawhaleHonorRanking() {
|
||||
const data = await this.rankingsService.syncNow();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '荣誉榜同步成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/business/rankings/rankings.module.ts
Normal file
12
src/business/rankings/rankings.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { RankingsController } from './rankings.controller';
|
||||
import { RankingsService } from './rankings.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [RankingsController],
|
||||
providers: [RankingsService],
|
||||
exports: [RankingsService],
|
||||
})
|
||||
export class RankingsModule {}
|
||||
436
src/business/rankings/rankings.service.ts
Normal file
436
src/business/rankings/rankings.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
88
src/business/rankings/rankings.types.ts
Normal file
88
src/business/rankings/rankings.types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type RankingCategoryId =
|
||||
| 'weekly_commits'
|
||||
| 'night_owl'
|
||||
| 'popularity'
|
||||
| 'productive'
|
||||
| 'social'
|
||||
| 'rising'
|
||||
| 'comprehensive';
|
||||
|
||||
export interface DatawhaleMemberRow {
|
||||
id?: string;
|
||||
name?: string;
|
||||
github?: string;
|
||||
domain?: string;
|
||||
primary_domain?: string;
|
||||
public_repos?: number;
|
||||
total_stars?: number;
|
||||
followers?: number;
|
||||
followers_count?: number;
|
||||
following?: number;
|
||||
org_repos_count?: number;
|
||||
org_total_stars?: number;
|
||||
org_total_contributions?: number;
|
||||
avatar?: string;
|
||||
location?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface DatawhaleCommitStats {
|
||||
total_commits?: number;
|
||||
repo_count?: number;
|
||||
active_days?: number;
|
||||
avg_commits_per_day?: number;
|
||||
night_owl_commits?: number;
|
||||
night_owl_percentage?: number;
|
||||
}
|
||||
|
||||
export interface DatawhaleWeeklyCommitsPayload {
|
||||
update_time?: string;
|
||||
days_range?: number;
|
||||
total_commits?: number;
|
||||
user_commits?: Record<string, DatawhaleCommitStats>;
|
||||
}
|
||||
|
||||
export interface RankingCategory {
|
||||
id: RankingCategoryId;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface RankingUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarText: string;
|
||||
avatarUrl: string;
|
||||
githubUrl: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
location: string;
|
||||
score: number;
|
||||
scoreLabel: string;
|
||||
contrib: number;
|
||||
answers: number;
|
||||
likes: number;
|
||||
reward: number;
|
||||
}
|
||||
|
||||
export interface RankingEntry extends RankingUser {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface DatawhaleHonorRankingPayload {
|
||||
source: 'datawhale-members-visualization';
|
||||
activeCategory: RankingCategoryId;
|
||||
categories: RankingCategory[];
|
||||
topRankers: RankingEntry[];
|
||||
rankers: RankingEntry[];
|
||||
myRank: {
|
||||
rank: number | null;
|
||||
score: number;
|
||||
reward: number;
|
||||
};
|
||||
total: number;
|
||||
syncedAt: string | null;
|
||||
sourceUpdatedAt: string | null;
|
||||
}
|
||||
Reference in New Issue
Block a user