Skip to content

Commit ab21edc

Browse files
committed
feat: SVG Badge API 구현
feat: SVG Badge API 구현 - Controller, Service, Repository 구현
1 parent 442f13e commit ab21edc

File tree

7 files changed

+298
-0
lines changed

7 files changed

+298
-0
lines changed

src/controllers/svg.controller.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { NextFunction, Request, RequestHandler, Response } from "express";
2+
import logger from '@/configs/logger.config'
3+
import { GetSvgBadgeParams, GetSvgBadgeQuery } from "@/types";
4+
import { SvgService } from '@/services/svg.service';
5+
6+
export class SvgController {
7+
constructor(private svgService: SvgService) {}
8+
9+
getSvgBadge: RequestHandler<GetSvgBadgeParams, any, any, GetSvgBadgeQuery> = async (
10+
req: Request<GetSvgBadgeParams, object, object, GetSvgBadgeQuery>,
11+
res: Response,
12+
next: NextFunction,
13+
) => {
14+
try {
15+
const { username } = req.params;
16+
const { type = 'default', assets = 'views,likes,posts', withrank = 'false'} = req.query;
17+
18+
const svgString = await this.svgService.generateBadgeSvg(
19+
username,
20+
type,
21+
assets,
22+
withrank === 'true',
23+
);
24+
25+
res.setHeader('Content-Type', 'image/svg+xml');
26+
res.setHeader('Cache-Control', 'public, max-age=1800');
27+
res.send(svgString);
28+
} catch (error) {
29+
logger.error('SVG Badge 생성 실패: ', error);
30+
next(error);
31+
}
32+
}
33+
}

src/repositories/svg.repository.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { Pool } from 'pg';
2+
import logger from '@/configs/logger.config';
3+
import { DBError, NotFoundError } from '@/exception';
4+
import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util';
5+
6+
export class SvgRepository {
7+
constructor(private pool: Pool) {}
8+
9+
async getUserBadgeData(username: string, withRank: boolean, dateRange: number = 30) {
10+
try {
11+
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
12+
const cteQuery = this.buildBadgeCteQuery(dateRange, pastDateKST);
13+
14+
const userStatsQuery = `
15+
${cteQuery}
16+
SELECT
17+
u.username,
18+
COALESCE(SUM(ts.today_view), 0) AS total_views,
19+
COALESCE(SUM(ts.today_like), 0) AS total_likes,
20+
COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts,
21+
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff,
22+
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff,
23+
COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' AND p.is_active = true THEN p.id END) AS post_diff
24+
FROM users_user u
25+
LEFT JOIN posts_post p ON p.user_id = u.id
26+
LEFT JOIN today_stats ts ON ts.post_id = p.id
27+
LEFT JOIN start_stats ss ON ss.post_id = p.id
28+
WHERE u.username = $1
29+
GROUP BY u.username
30+
`;
31+
const userStatsResult = await this.pool.query(userStatsQuery, [username, dateRange]);
32+
33+
if (userStatsResult.rows.length === 0) {
34+
throw new NotFoundError(`사용자를 찾을 수 없습니다: ${username}`);
35+
}
36+
37+
const recentPostsQuery = `
38+
${cteQuery}
39+
SELECT
40+
p.title,
41+
p.released_at,
42+
COALESCE(ts.today_view, 0) AS today_view,
43+
COALESCE(ts.today_like, 0) AS today_like,
44+
COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) AS view_diff
45+
FROM posts_post p
46+
JOIN users_user u ON u.id = p.user_id
47+
LEFT JOIN today_stats ts ON ts.post_id = p.id
48+
LEFT JOIN start_stats ss ON ss.post_id = p.id
49+
WHERE u.username = $1
50+
AND p.is_active = true
51+
ORDER BY p.released_at DESC
52+
LIMIT 3
53+
`;
54+
const recentPostsResult = await this.pool.query(recentPostsQuery, [username]);
55+
56+
return {
57+
...userStatsResult.rows[0],
58+
recent_posts: recentPostsResult.rows,
59+
};
60+
} catch (error) {
61+
if (error instanceof NotFoundError) {
62+
throw error;
63+
}
64+
65+
logger.error('SvgRepository getUserBadgeData error: ', error);
66+
throw new DBError('배지 데이터 조회 중 문제가 발생했습니다.');
67+
}
68+
}
69+
70+
private buildBadgeCteQuery(dateRange: number, pastDateKST?: string) {
71+
const nowDateKST =
72+
new Date().getUTCHours() === 15
73+
? getKSTDateStringWithOffset(-24 * 60)
74+
: getCurrentKSTDateString();
75+
76+
if (!pastDateKST) {
77+
pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
78+
}
79+
80+
return `
81+
WITH
82+
today_stats AS (
83+
SELECT DISTINCT ON (post_id)
84+
post_id,
85+
daily_view_count AS today_view,
86+
daily_like_count AS today_like
87+
FROM posts_postdailystatistics
88+
WHERE date = '${nowDateKST}'
89+
),
90+
start_stats AS (
91+
SELECT DISTINCT ON (post_id)
92+
post_id,
93+
daily_view_count AS start_view,
94+
daily_like_count AS start_like
95+
FROM posts_postdailystatistics
96+
WHERE date = '${pastDateKST}'
97+
)
98+
`;
99+
}
100+
}

src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import PostRouter from './post.router';
44
import NotiRouter from './noti.router';
55
import LeaderboardRouter from './leaderboard.router';
66
import TotalStatsRouter from './totalStats.router';
7+
import SvgRouter from './svg.router';
78
import WebhookRouter from './webhook.router';
89

910
const router: Router = express.Router();
@@ -17,6 +18,7 @@ router.use('/', PostRouter);
1718
router.use('/', NotiRouter);
1819
router.use('/', LeaderboardRouter);
1920
router.use('/', TotalStatsRouter);
21+
router.use('/', SvgRouter);
2022
router.use('/', WebhookRouter);
2123

2224
export default router;

src/routes/svg.router.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pool from '@/configs/db.config';
2+
import express, { Router } from 'express';
3+
import { SvgRepository } from '@/repositories/svg.repository';
4+
import { SvgService } from '@/services/svg.service';
5+
import { SvgController } from '@/controllers/svg.controller';
6+
import { validateRequestDto } from '@/middlewares/validation.middleware';
7+
import { GetSvgBadgeQueryDto } from '@/types';
8+
9+
const router: Router = express.Router();
10+
11+
const svgRepository = new SvgRepository(pool);
12+
const svgService = new SvgService(svgRepository);
13+
const svgController = new SvgController(svgService);
14+
15+
/**
16+
* @swagger
17+
* /api/{username}/badge:
18+
* get:
19+
* summary: 사용자 배지 SVG 조회
20+
* tags:
21+
* - SVG
22+
* security: []
23+
* parameters:
24+
* - in: path
25+
* name: username
26+
* required: true
27+
* schema:
28+
* type: string
29+
* description: 조회할 사용자명
30+
* example: six-standard
31+
* - in: query
32+
* name: type
33+
* schema:
34+
* type: string
35+
* enum: [default, simple]
36+
* default: default
37+
* - in: query
38+
* name: assets
39+
* schema:
40+
* type: string
41+
* default: views,likes,posts
42+
* example: views,likes,posts
43+
* - in: query
44+
* name: withrank
45+
* schema:
46+
* type: string
47+
* enum: [true, false]
48+
* default: false
49+
* responses:
50+
* '200':
51+
* description: SVG 배지 생성 성공
52+
* content:
53+
* image/svg+xml:
54+
* schema:
55+
* type: string
56+
* example: <svg>...</svg>
57+
* '404':
58+
* description: 사용자를 찾을 수 없음
59+
* '500':
60+
* description: 서버 오류 / 데이터 베이스 조회 오류
61+
*/
62+
router.get(
63+
'/:username/badge',
64+
validateRequestDto(GetSvgBadgeQueryDto, 'query'),
65+
svgController.getSvgBadge as any,
66+
);
67+
68+
export default router;

src/services/svg.service.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import logger from '@/configs/logger.config';
2+
import { SvgBadgeType } from '@/types';
3+
import { SvgRepository } from '@/repositories/svg.repository';
4+
5+
export class SvgService {
6+
constructor(private svgRepo: SvgRepository) {}
7+
8+
async generateBadgeSvg(
9+
username: string,
10+
type: SvgBadgeType,
11+
assets: string,
12+
withRank: boolean,
13+
): Promise<string> {
14+
try {
15+
const data = await this.svgRepo.getUserBadgeData(username, withRank);
16+
17+
if (type === 'simple') {
18+
return this.generateSimpleSvg(data, assets);
19+
} else {
20+
return this.generateDefaultSvg(data, assets, withRank);
21+
}
22+
} catch (error) {
23+
logger.error('SvgService generateBadgeSvg error: ', error);
24+
throw error;
25+
}
26+
}
27+
28+
private generateSimpleSvg(data: any, assets: string): string {
29+
return `<svg width="400" height="120" xmlns="http://www.w3.org/2000/svg">
30+
<rect width="400" height="120" fill="#1E1E1E"/>
31+
<text x="20" y="40" fill="white" font-size="20">${data.username}</text>
32+
<text x="20" y="70" fill="white">Views: ${data.total_views}</text>
33+
<text x="20" y="100" fill="white">Likes: ${data.total_likes}</text>
34+
</svg>`;
35+
}
36+
37+
private generateDefaultSvg(data: any, assets: string, withRank: boolean): string {
38+
return `<svg width="600" height="300" xmlns="http://www.w3.org/2000/svg">
39+
<rect width="600" height="300" fill="#1E1E1E"/>
40+
<text x="20" y="30" fill="white" font-size="18">${data.username}</text>
41+
<text x="20" y="60" fill="white">Views: ${data.total_views}</text>
42+
<text x="20" y="90" fill="white">Recent Posts: ${data.recent_posts?.length || 0}</text>
43+
${withRank ? `<text x="20" y="120" fill="white">Rank: #${data.view_rank || 'N/A'}</text>` : ''}
44+
</svg>`;
45+
}
46+
47+
private formatNumber(num: number): string {
48+
if (num >= 1000000) {
49+
return (num / 1000000).toFixed(1) + 'm';
50+
}
51+
52+
if (num >= 1000) {
53+
return (num / 1000).toFixed(1) + 'k';
54+
}
55+
56+
return num.toString();
57+
}
58+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { IsEnum, IsOptional, IsString } from 'class-validator';
2+
3+
export type SvgBadgeType = 'default' | 'simple';
4+
5+
export interface GetSvgBadgeParams {
6+
username: string;
7+
}
8+
9+
export interface GetSvgBadgeQuery {
10+
type?: SvgBadgeType;
11+
assets?: string;
12+
withrank?: string;
13+
}
14+
15+
export class GetSvgBadgeQueryDto {
16+
@IsOptional()
17+
@IsEnum(['default', 'simple'])
18+
type?: SvgBadgeType;
19+
20+
@IsOptional()
21+
@IsString()
22+
assets?: string;
23+
24+
@IsOptional()
25+
@IsEnum(['true', 'false'])
26+
withrank?: string;
27+
28+
constructor(type?: SvgBadgeType, assets?: string, withrank?: string) {
29+
this.type = type || 'default';
30+
this.assets = assets || 'views,likes,posts'
31+
this.withrank = withrank || 'false';
32+
}
33+
}

src/types/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty
3838
export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type';
3939
export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type';
4040

41+
// SVG 관련
42+
export type { GetSvgBadgeParams, GetSvgBadgeQuery, SvgBadgeType } from '@/types/dto/requests/svgRequest.type';
43+
export { GetSvgBadgeQueryDto } from '@/types/dto/requests/svgRequest.type';
44+
4145
// Sentry 관련
4246
export type { SentryIssueStatus } from '@/types/models/Sentry.type';
4347
export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type';

0 commit comments

Comments
 (0)