diff --git a/src/business/rankings/rankings.controller.ts b/src/business/rankings/rankings.controller.ts index dffc518..146d825 100644 --- a/src/business/rankings/rankings.controller.ts +++ b/src/business/rankings/rankings.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Query } from '@nestjs/common'; +import { Controller, Get, Header, Param, Query, Res } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Response } from 'express'; import { RankingsService } from './rankings.service'; import { RankingCategoryId } from './rankings.types'; @@ -8,6 +9,17 @@ import { RankingCategoryId } from './rankings.types'; export class RankingsController { constructor(private readonly rankingsService: RankingsService) {} + @Get('datawhale-honor/avatar/:memberId') + @Header('Cache-Control', 'public, max-age=86400, stale-while-revalidate=604800') + @ApiOperation({ summary: '代理 Datawhale 荣誉榜成员头像' }) + async getDatawhaleMemberAvatar( + @Param('memberId') memberId: string, + @Res() res: Response, + ): Promise { + const avatar = await this.rankingsService.getMemberAvatar(memberId); + res.type(avatar.contentType).send(avatar.body); + } + @Get('datawhale-honor') @ApiOperation({ summary: '获取Datawhale荣誉榜', diff --git a/src/business/rankings/rankings.service.ts b/src/business/rankings/rankings.service.ts index 63d35eb..14e1512 100644 --- a/src/business/rankings/rankings.service.ts +++ b/src/business/rankings/rankings.service.ts @@ -1,4 +1,4 @@ -import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { BadGatewayException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import axios from 'axios'; import { @@ -17,6 +17,12 @@ const DATAWHALE_WEEKLY_COMMITS_URL = 'https://mv.datawhale.cc/data/commits_weekl const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/'; const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits'; const DEFAULT_LIMIT = 10; +const MAX_AVATAR_BYTES = 2 * 1024 * 1024; + +export interface RankingAvatarPayload { + body: Buffer; + contentType: string; +} const CATEGORIES: RankingCategory[] = [ { @@ -115,6 +121,40 @@ export class RankingsService implements OnModuleInit { return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT); } + async getMemberAvatar(memberId: string): Promise { + const normalizedId = this.cleanString(memberId); + const member = this.members.find(item => this.cleanString(item.id) === normalizedId); + if (!member) { + throw new NotFoundException('榜单成员不存在'); + } + const avatarUrl = this.sourceAvatarUrl(member); + if (!avatarUrl) { + throw new NotFoundException('榜单成员没有头像'); + } + try { + const response = await axios.get(avatarUrl, { + responseType: 'arraybuffer', + timeout: 10000, + maxContentLength: MAX_AVATAR_BYTES, + maxBodyLength: MAX_AVATAR_BYTES, + }); + const body = Buffer.from(response.data); + if (body.length === 0 || body.length > MAX_AVATAR_BYTES) { + throw new BadGatewayException('头像源返回的文件大小异常'); + } + const contentType = this.detectImageContentType(body); + if (!contentType) { + throw new BadGatewayException('头像源返回了不支持的文件类型'); + } + return { body, contentType }; + } catch (error) { + if (error instanceof BadGatewayException) { + throw error; + } + throw new BadGatewayException(`头像源请求失败:${this.errorMessage(error)}`); + } + } + private getCachedPayload( category: RankingCategoryId, limit: number, @@ -366,6 +406,13 @@ export class RankingsService implements OnModuleInit { } private avatarUrl(member: DatawhaleMemberRow): string { + const id = this.cleanString(member.id); + return id && this.sourceAvatarUrl(member) + ? `/api/rankings/datawhale-honor/avatar/${encodeURIComponent(id)}` + : ''; + } + + private sourceAvatarUrl(member: DatawhaleMemberRow): string { const avatar = this.cleanString(member.avatar); if (!avatar) { return ''; @@ -423,6 +470,19 @@ export class RankingsService implements OnModuleInit { return Number.isFinite(parsed) ? parsed : 0; } + private detectImageContentType(body: Buffer): string { + if (body.length >= 8 && body.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (body.length >= 3 && body[0] === 0xff && body[1] === 0xd8 && body[2] === 0xff) { + return 'image/jpeg'; + } + if (body.length >= 12 && body.toString('ascii', 0, 4) === 'RIFF' && body.toString('ascii', 8, 12) === 'WEBP') { + return 'image/webp'; + } + return ''; + } + private cleanString(value: unknown): string { return String(value ?? '').trim(); }