From ab21edcddda5cd30aa6620108bb5295575330664 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Fri, 12 Dec 2025 13:27:12 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20SVG=20Badge=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: SVG Badge API 구현 - Controller, Service, Repository 구현 --- src/controllers/svg.controller.ts | 33 +++++++ src/repositories/svg.repository.ts | 100 ++++++++++++++++++++++ src/routes/index.ts | 2 + src/routes/svg.router.ts | 68 +++++++++++++++ src/services/svg.service.ts | 58 +++++++++++++ src/types/dto/requests/svgRequest.type.ts | 33 +++++++ src/types/index.ts | 4 + 7 files changed, 298 insertions(+) create mode 100644 src/controllers/svg.controller.ts create mode 100644 src/repositories/svg.repository.ts create mode 100644 src/routes/svg.router.ts create mode 100644 src/services/svg.service.ts create mode 100644 src/types/dto/requests/svgRequest.type.ts diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts new file mode 100644 index 0000000..de4d455 --- /dev/null +++ b/src/controllers/svg.controller.ts @@ -0,0 +1,33 @@ +import { NextFunction, Request, RequestHandler, Response } from "express"; +import logger from '@/configs/logger.config' +import { GetSvgBadgeParams, GetSvgBadgeQuery } from "@/types"; +import { SvgService } from '@/services/svg.service'; + +export class SvgController { + constructor(private svgService: SvgService) {} + + getSvgBadge: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { username } = req.params; + const { type = 'default', assets = 'views,likes,posts', withrank = 'false'} = req.query; + + const svgString = await this.svgService.generateBadgeSvg( + username, + type, + assets, + withrank === 'true', + ); + + res.setHeader('Content-Type', 'image/svg+xml'); + res.setHeader('Cache-Control', 'public, max-age=1800'); + res.send(svgString); + } catch (error) { + logger.error('SVG Badge 생성 실패: ', error); + next(error); + } + } +} diff --git a/src/repositories/svg.repository.ts b/src/repositories/svg.repository.ts new file mode 100644 index 0000000..161b6e6 --- /dev/null +++ b/src/repositories/svg.repository.ts @@ -0,0 +1,100 @@ +import { Pool } from 'pg'; +import logger from '@/configs/logger.config'; +import { DBError, NotFoundError } from '@/exception'; +import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; + +export class SvgRepository { + constructor(private pool: Pool) {} + + async getUserBadgeData(username: string, withRank: boolean, dateRange: number = 30) { + try { + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + const cteQuery = this.buildBadgeCteQuery(dateRange, pastDateKST); + + const userStatsQuery = ` + ${cteQuery} + SELECT + u.username, + COALESCE(SUM(ts.today_view), 0) AS total_views, + COALESCE(SUM(ts.today_like), 0) AS total_likes, + COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts, + SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff, + SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff, + COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' AND p.is_active = true THEN p.id END) AS post_diff + FROM users_user u + LEFT JOIN posts_post p ON p.user_id = u.id + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE u.username = $1 + GROUP BY u.username + `; + const userStatsResult = await this.pool.query(userStatsQuery, [username, dateRange]); + + if (userStatsResult.rows.length === 0) { + throw new NotFoundError(`사용자를 찾을 수 없습니다: ${username}`); + } + + const recentPostsQuery = ` + ${cteQuery} + SELECT + p.title, + p.released_at, + COALESCE(ts.today_view, 0) AS today_view, + COALESCE(ts.today_like, 0) AS today_like, + COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) AS view_diff + FROM posts_post p + JOIN users_user u ON u.id = p.user_id + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE u.username = $1 + AND p.is_active = true + ORDER BY p.released_at DESC + LIMIT 3 + `; + const recentPostsResult = await this.pool.query(recentPostsQuery, [username]); + + return { + ...userStatsResult.rows[0], + recent_posts: recentPostsResult.rows, + }; + } catch (error) { + if (error instanceof NotFoundError) { + throw error; + } + + logger.error('SvgRepository getUserBadgeData error: ', error); + throw new DBError('배지 데이터 조회 중 문제가 발생했습니다.'); + } + } + + private buildBadgeCteQuery(dateRange: number, pastDateKST?: string) { + const nowDateKST = + new Date().getUTCHours() === 15 + ? getKSTDateStringWithOffset(-24 * 60) + : getCurrentKSTDateString(); + + if (!pastDateKST) { + pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + } + + return ` + WITH + today_stats AS ( + SELECT DISTINCT ON (post_id) + post_id, + daily_view_count AS today_view, + daily_like_count AS today_like + FROM posts_postdailystatistics + WHERE date = '${nowDateKST}' + ), + start_stats AS ( + SELECT DISTINCT ON (post_id) + post_id, + daily_view_count AS start_view, + daily_like_count AS start_like + FROM posts_postdailystatistics + WHERE date = '${pastDateKST}' + ) + `; + } +} \ No newline at end of file diff --git a/src/routes/index.ts b/src/routes/index.ts index 3b87fac..304757c 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -4,6 +4,7 @@ import PostRouter from './post.router'; import NotiRouter from './noti.router'; import LeaderboardRouter from './leaderboard.router'; import TotalStatsRouter from './totalStats.router'; +import SvgRouter from './svg.router'; import WebhookRouter from './webhook.router'; const router: Router = express.Router(); @@ -17,6 +18,7 @@ router.use('/', PostRouter); router.use('/', NotiRouter); router.use('/', LeaderboardRouter); router.use('/', TotalStatsRouter); +router.use('/', SvgRouter); router.use('/', WebhookRouter); export default router; diff --git a/src/routes/svg.router.ts b/src/routes/svg.router.ts new file mode 100644 index 0000000..3303813 --- /dev/null +++ b/src/routes/svg.router.ts @@ -0,0 +1,68 @@ +import pool from '@/configs/db.config'; +import express, { Router } from 'express'; +import { SvgRepository } from '@/repositories/svg.repository'; +import { SvgService } from '@/services/svg.service'; +import { SvgController } from '@/controllers/svg.controller'; +import { validateRequestDto } from '@/middlewares/validation.middleware'; +import { GetSvgBadgeQueryDto } from '@/types'; + +const router: Router = express.Router(); + +const svgRepository = new SvgRepository(pool); +const svgService = new SvgService(svgRepository); +const svgController = new SvgController(svgService); + +/** + * @swagger + * /api/{username}/badge: + * get: + * summary: 사용자 배지 SVG 조회 + * tags: + * - SVG + * security: [] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: 조회할 사용자명 + * example: six-standard + * - in: query + * name: type + * schema: + * type: string + * enum: [default, simple] + * default: default + * - in: query + * name: assets + * schema: + * type: string + * default: views,likes,posts + * example: views,likes,posts + * - in: query + * name: withrank + * schema: + * type: string + * enum: [true, false] + * default: false + * responses: + * '200': + * description: SVG 배지 생성 성공 + * content: + * image/svg+xml: + * schema: + * type: string + * example: ... + * '404': + * description: 사용자를 찾을 수 없음 + * '500': + * description: 서버 오류 / 데이터 베이스 조회 오류 + */ +router.get( + '/:username/badge', + validateRequestDto(GetSvgBadgeQueryDto, 'query'), + svgController.getSvgBadge as any, +); + +export default router; diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts new file mode 100644 index 0000000..bf48ce9 --- /dev/null +++ b/src/services/svg.service.ts @@ -0,0 +1,58 @@ +import logger from '@/configs/logger.config'; +import { SvgBadgeType } from '@/types'; +import { SvgRepository } from '@/repositories/svg.repository'; + +export class SvgService { + constructor(private svgRepo: SvgRepository) {} + + async generateBadgeSvg( + username: string, + type: SvgBadgeType, + assets: string, + withRank: boolean, + ): Promise { + try { + const data = await this.svgRepo.getUserBadgeData(username, withRank); + + if (type === 'simple') { + return this.generateSimpleSvg(data, assets); + } else { + return this.generateDefaultSvg(data, assets, withRank); + } + } catch (error) { + logger.error('SvgService generateBadgeSvg error: ', error); + throw error; + } + } + + private generateSimpleSvg(data: any, assets: string): string { + return ` + + ${data.username} + Views: ${data.total_views} + Likes: ${data.total_likes} + `; + } + + private generateDefaultSvg(data: any, assets: string, withRank: boolean): string { + return ` + + ${data.username} + Views: ${data.total_views} + Recent Posts: ${data.recent_posts?.length || 0} + ${withRank ? `Rank: #${data.view_rank || 'N/A'}` : ''} + `; + } + + private formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'm'; + } + + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'k'; + } + + return num.toString(); + } +} \ No newline at end of file diff --git a/src/types/dto/requests/svgRequest.type.ts b/src/types/dto/requests/svgRequest.type.ts new file mode 100644 index 0000000..8065bab --- /dev/null +++ b/src/types/dto/requests/svgRequest.type.ts @@ -0,0 +1,33 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export type SvgBadgeType = 'default' | 'simple'; + +export interface GetSvgBadgeParams { + username: string; +} + +export interface GetSvgBadgeQuery { + type?: SvgBadgeType; + assets?: string; + withrank?: string; +} + +export class GetSvgBadgeQueryDto { + @IsOptional() + @IsEnum(['default', 'simple']) + type?: SvgBadgeType; + + @IsOptional() + @IsString() + assets?: string; + + @IsOptional() + @IsEnum(['true', 'false']) + withrank?: string; + + constructor(type?: SvgBadgeType, assets?: string, withrank?: string) { + this.type = type || 'default'; + this.assets = assets || 'views,likes,posts' + this.withrank = withrank || 'false'; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 5fae7a7..4f772e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,10 @@ export type { TotalStatsItem } from '@/types/dto/responses/totalStatsResponse.ty export { GetTotalStatsQueryDto } from '@/types/dto/requests/getTotalStatsQuery.type'; export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.type'; +// SVG 관련 +export type { GetSvgBadgeParams, GetSvgBadgeQuery, SvgBadgeType } from '@/types/dto/requests/svgRequest.type'; +export { GetSvgBadgeQueryDto } from '@/types/dto/requests/svgRequest.type'; + // Sentry 관련 export type { SentryIssueStatus } from '@/types/models/Sentry.type'; export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type'; From 1b8086b9f88f4b580b9295697fb05d63b5183b7d Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Fri, 12 Dec 2025 17:30:49 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=EC=BF=BC=EB=A6=AC=20=ED=8C=8C?= =?UTF-8?q?=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: 쿼리 파라미터 오류 수정 - userStatsQuery에 불필요한 ateRange 파라미터 제거 --- src/repositories/svg.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/svg.repository.ts b/src/repositories/svg.repository.ts index 161b6e6..d6ca763 100644 --- a/src/repositories/svg.repository.ts +++ b/src/repositories/svg.repository.ts @@ -28,7 +28,7 @@ export class SvgRepository { WHERE u.username = $1 GROUP BY u.username `; - const userStatsResult = await this.pool.query(userStatsQuery, [username, dateRange]); + const userStatsResult = await this.pool.query(userStatsQuery, [username]); if (userStatsResult.rows.length === 0) { throw new NotFoundError(`사용자를 찾을 수 없습니다: ${username}`); From ea0407f3fc8938fa096506face31643769b889a2 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Sat, 13 Dec 2025 15:40:59 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refactor:=20SVG=20Badge=20API=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20Json=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: SVG Badge API 구조 개선 및 응답 Json으로 변경 - assets, withrank 쿼리 파라미터 삭제 - API 응답 Svg에서 Json으로 변경 --- src/controllers/svg.controller.ts | 13 +-- src/repositories/leaderboard.repository.ts | 70 +++++++++++++- src/repositories/svg.repository.ts | 100 -------------------- src/routes/svg.router.ts | 53 ++++++----- src/services/svg.service.ts | 59 ++++++------ src/types/dto/requests/svgRequest.type.ts | 6 +- src/types/dto/responses/svgResponse.type.ts | 27 ++++++ src/types/index.ts | 2 + 8 files changed, 164 insertions(+), 166 deletions(-) delete mode 100644 src/repositories/svg.repository.ts create mode 100644 src/types/dto/responses/svgResponse.type.ts diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts index de4d455..ec395a2 100644 --- a/src/controllers/svg.controller.ts +++ b/src/controllers/svg.controller.ts @@ -13,18 +13,11 @@ export class SvgController { ) => { try { const { username } = req.params; - const { type = 'default', assets = 'views,likes,posts', withrank = 'false'} = req.query; + const { type = 'default'} = req.query; - const svgString = await this.svgService.generateBadgeSvg( - username, - type, - assets, - withrank === 'true', - ); + const data = await this.svgService.getBadgeData(username, type); - res.setHeader('Content-Type', 'image/svg+xml'); - res.setHeader('Cache-Control', 'public, max-age=1800'); - res.send(svgString); + res.json(data); } catch (error) { logger.error('SVG Badge 생성 실패: ', error); next(error); diff --git a/src/repositories/leaderboard.repository.ts b/src/repositories/leaderboard.repository.ts index a184e53..c6f200b 100644 --- a/src/repositories/leaderboard.repository.ts +++ b/src/repositories/leaderboard.repository.ts @@ -1,6 +1,6 @@ import logger from '@/configs/logger.config'; import { Pool } from 'pg'; -import { DBError } from '@/exception'; +import { DBError, NotFoundError } from '@/exception'; import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index'; import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; @@ -82,6 +82,74 @@ export class LeaderboardRepository { } } + async getUserStats(username: string, dateRange: number = 30) { + try { + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST); + + const query = ` + ${cteQuery} + SELECT + u.username, + COALESCE(SUM(ts.today_view), 0) AS total_views, + COALESCE(SUM(ts.today_like), 0) AS total_likes, + COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts, + SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff, + SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff, + COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' AND p.is_active = true THEN p.id END) AS post_diff + FROM users_user u + LEFT JOIN posts_post p ON p.user_id = u.id + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE u.username = $1 + GROUP BY u.username + `; + + const result = await this.pool.query(query, [username]); + + if (result.rows.length === 0) { + throw new NotFoundError(`사용자를 찾을 수 없습니다: ${username}`); + } + + return result.rows[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + logger.error('LeaderboardRepository getUserStats error:', error); + throw new DBError('사용자 통계 조회 중 문제가 발생했습니다.'); + } + } + + async getRecentPosts(username: string, dateRange: number = 30, limit: number = 3) { + try { + const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); + const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST); + + const query = ` + ${cteQuery} + SELECT + p.title, + p.released_at, + COALESCE(ts.today_view, 0) AS today_view, + COALESCE(ts.today_like, 0) AS today_like, + (COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff + FROM posts_post p + JOIN users_user u ON u.id = p.user_id + LEFT JOIN today_stats ts ON ts.post_id = p.id + LEFT JOIN start_stats ss ON ss.post_id = p.id + WHERE u.username = $1 + AND p.is_active = true + ORDER BY p.released_at DESC + LIMIT $2 + `; + + const result = await this.pool.query(query, [username, limit]); + return result.rows; + } catch (error) { + logger.error('LeaderboardRepository getRecentPosts error:', error); + throw new DBError('최근 게시글 조회 중 문제가 발생했습니다.'); + } + } + // 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드 private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) { // KST 기준 00시~01시 (UTC 15:00~16:00) 사이라면 전날 데이터를 사용 diff --git a/src/repositories/svg.repository.ts b/src/repositories/svg.repository.ts deleted file mode 100644 index d6ca763..0000000 --- a/src/repositories/svg.repository.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Pool } from 'pg'; -import logger from '@/configs/logger.config'; -import { DBError, NotFoundError } from '@/exception'; -import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util'; - -export class SvgRepository { - constructor(private pool: Pool) {} - - async getUserBadgeData(username: string, withRank: boolean, dateRange: number = 30) { - try { - const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); - const cteQuery = this.buildBadgeCteQuery(dateRange, pastDateKST); - - const userStatsQuery = ` - ${cteQuery} - SELECT - u.username, - COALESCE(SUM(ts.today_view), 0) AS total_views, - COALESCE(SUM(ts.today_like), 0) AS total_likes, - COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts, - SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff, - SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff, - COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' AND p.is_active = true THEN p.id END) AS post_diff - FROM users_user u - LEFT JOIN posts_post p ON p.user_id = u.id - LEFT JOIN today_stats ts ON ts.post_id = p.id - LEFT JOIN start_stats ss ON ss.post_id = p.id - WHERE u.username = $1 - GROUP BY u.username - `; - const userStatsResult = await this.pool.query(userStatsQuery, [username]); - - if (userStatsResult.rows.length === 0) { - throw new NotFoundError(`사용자를 찾을 수 없습니다: ${username}`); - } - - const recentPostsQuery = ` - ${cteQuery} - SELECT - p.title, - p.released_at, - COALESCE(ts.today_view, 0) AS today_view, - COALESCE(ts.today_like, 0) AS today_like, - COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) AS view_diff - FROM posts_post p - JOIN users_user u ON u.id = p.user_id - LEFT JOIN today_stats ts ON ts.post_id = p.id - LEFT JOIN start_stats ss ON ss.post_id = p.id - WHERE u.username = $1 - AND p.is_active = true - ORDER BY p.released_at DESC - LIMIT 3 - `; - const recentPostsResult = await this.pool.query(recentPostsQuery, [username]); - - return { - ...userStatsResult.rows[0], - recent_posts: recentPostsResult.rows, - }; - } catch (error) { - if (error instanceof NotFoundError) { - throw error; - } - - logger.error('SvgRepository getUserBadgeData error: ', error); - throw new DBError('배지 데이터 조회 중 문제가 발생했습니다.'); - } - } - - private buildBadgeCteQuery(dateRange: number, pastDateKST?: string) { - const nowDateKST = - new Date().getUTCHours() === 15 - ? getKSTDateStringWithOffset(-24 * 60) - : getCurrentKSTDateString(); - - if (!pastDateKST) { - pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60); - } - - return ` - WITH - today_stats AS ( - SELECT DISTINCT ON (post_id) - post_id, - daily_view_count AS today_view, - daily_like_count AS today_like - FROM posts_postdailystatistics - WHERE date = '${nowDateKST}' - ), - start_stats AS ( - SELECT DISTINCT ON (post_id) - post_id, - daily_view_count AS start_view, - daily_like_count AS start_like - FROM posts_postdailystatistics - WHERE date = '${pastDateKST}' - ) - `; - } -} \ No newline at end of file diff --git a/src/routes/svg.router.ts b/src/routes/svg.router.ts index 3303813..e01a317 100644 --- a/src/routes/svg.router.ts +++ b/src/routes/svg.router.ts @@ -1,6 +1,6 @@ import pool from '@/configs/db.config'; import express, { Router } from 'express'; -import { SvgRepository } from '@/repositories/svg.repository'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { SvgService } from '@/services/svg.service'; import { SvgController } from '@/controllers/svg.controller'; import { validateRequestDto } from '@/middlewares/validation.middleware'; @@ -8,15 +8,15 @@ import { GetSvgBadgeQueryDto } from '@/types'; const router: Router = express.Router(); -const svgRepository = new SvgRepository(pool); -const svgService = new SvgService(svgRepository); +const leaderboardRepository = new LeaderboardRepository(pool); +const svgService = new SvgService(leaderboardRepository); const svgController = new SvgController(svgService); /** * @swagger * /api/{username}/badge: * get: - * summary: 사용자 배지 SVG 조회 + * summary: 사용자 배지 데이터 조회 * tags: * - SVG * security: [] @@ -27,37 +27,46 @@ const svgController = new SvgController(svgService); * schema: * type: string * description: 조회할 사용자명 - * example: six-standard + * example: ljh3478 * - in: query * name: type * schema: * type: string * enum: [default, simple] * default: default - * - in: query - * name: assets - * schema: - * type: string - * default: views,likes,posts - * example: views,likes,posts - * - in: query - * name: withrank - * schema: - * type: string - * enum: [true, false] - * default: false * responses: * '200': - * description: SVG 배지 생성 성공 + * description: 배지 데이터 조회 성공 * content: - * image/svg+xml: + * application/json: * schema: - * type: string - * example: ... + * type: object + * properties: + * user: + * type: object + * properties: + * username: + * type: string + * totalViews: + * type: number + * totalLikes: + * type: number + * totalPosts: + * type: number + * viewDiff: + * type: number + * likeDiff: + * type: number + * postDiff: + * type: number + * recentPosts: + * type: array + * items: + * type: object * '404': * description: 사용자를 찾을 수 없음 * '500': - * description: 서버 오류 / 데이터 베이스 조회 오류 + * description: 서버 오류 */ router.get( '/:username/badge', diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts index bf48ce9..1a47b5e 100644 --- a/src/services/svg.service.ts +++ b/src/services/svg.service.ts @@ -1,26 +1,41 @@ import logger from '@/configs/logger.config'; -import { SvgBadgeType } from '@/types'; -import { SvgRepository } from '@/repositories/svg.repository'; +import { BadgeDataResponseDto, SvgBadgeType } from '@/types'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; export class SvgService { - constructor(private svgRepo: SvgRepository) {} + constructor(private leaderboardRepo: LeaderboardRepository) {} - async generateBadgeSvg( + async getBadgeData( username: string, type: SvgBadgeType, - assets: string, - withRank: boolean, - ): Promise { + dateRange: number = 30, + ): Promise { try { - const data = await this.svgRepo.getUserBadgeData(username, withRank); - - if (type === 'simple') { - return this.generateSimpleSvg(data, assets); - } else { - return this.generateDefaultSvg(data, assets, withRank); - } + const userStats = await this.leaderboardRepo.getUserStats(username, dateRange); + const recentPosts = type === 'default' + ? await this.leaderboardRepo.getRecentPosts(username, dateRange, 3) + : []; + + return new BadgeDataResponseDto( + { + username: userStats.username, + totalViews: Number(userStats.total_views), + totalLikes: Number(userStats.total_likes), + totalPosts: Number(userStats.total_posts), + viewDiff: Number(userStats.view_diff), + likeDiff: Number(userStats.like_diff), + postDiff: Number(userStats.post_diff), + }, + recentPosts.map(post => ({ + title: post.title, + releasedAt: post.released_at, + viewCount: Number(post.today_view), + likeCount: Number(post.today_like), + viewDiff: Number(post.view_diff), + })) + ) } catch (error) { - logger.error('SvgService generateBadgeSvg error: ', error); + logger.error('SvgService getBadgeData error: ', error); throw error; } } @@ -43,16 +58,4 @@ export class SvgService { ${withRank ? `Rank: #${data.view_rank || 'N/A'}` : ''} `; } - - private formatNumber(num: number): string { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + 'm'; - } - - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'k'; - } - - return num.toString(); - } -} \ No newline at end of file +} diff --git a/src/types/dto/requests/svgRequest.type.ts b/src/types/dto/requests/svgRequest.type.ts index 8065bab..18a5676 100644 --- a/src/types/dto/requests/svgRequest.type.ts +++ b/src/types/dto/requests/svgRequest.type.ts @@ -8,8 +8,6 @@ export interface GetSvgBadgeParams { export interface GetSvgBadgeQuery { type?: SvgBadgeType; - assets?: string; - withrank?: string; } export class GetSvgBadgeQueryDto { @@ -25,9 +23,7 @@ export class GetSvgBadgeQueryDto { @IsEnum(['true', 'false']) withrank?: string; - constructor(type?: SvgBadgeType, assets?: string, withrank?: string) { + constructor(type?: SvgBadgeType) { this.type = type || 'default'; - this.assets = assets || 'views,likes,posts' - this.withrank = withrank || 'false'; } } diff --git a/src/types/dto/responses/svgResponse.type.ts b/src/types/dto/responses/svgResponse.type.ts new file mode 100644 index 0000000..6486691 --- /dev/null +++ b/src/types/dto/responses/svgResponse.type.ts @@ -0,0 +1,27 @@ +export interface BadgeUserData { + username: string; + totalViews: number; + totalLikes: number; + totalPosts: number; + viewDiff: number; + likeDiff: number; + postDiff: number; +} + +export interface BadgeRecentPost { + title: string; + releasedAt: string; + viewCount: number; + likeCount: number; + viewDiff: number; +} + +export class BadgeDataResponseDto { + user: BadgeUserData; + recentPosts: BadgeRecentPost[]; + + constructor(user: BadgeUserData, recentPosts: BadgeRecentPost[]) { + this.user = user; + this.recentPosts = recentPosts; + } +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 4f772e2..24ed2f8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,6 +41,8 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse. // SVG 관련 export type { GetSvgBadgeParams, GetSvgBadgeQuery, SvgBadgeType } from '@/types/dto/requests/svgRequest.type'; export { GetSvgBadgeQueryDto } from '@/types/dto/requests/svgRequest.type'; +export type { BadgeUserData, BadgeRecentPost } from '@/types/dto/responses/svgResponse.type'; +export { BadgeDataResponseDto } from '@/types/dto/responses/svgResponse.type'; // Sentry 관련 export type { SentryIssueStatus } from '@/types/models/Sentry.type'; From ffdc0f087ebf19d1c233bb9dd332f65b182fceb1 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 00:21:03 +0900 Subject: [PATCH 4/9] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EB=B0=8F=20=ED=95=A8=EC=88=98=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/svg.service.ts | 19 ------------------- src/types/dto/requests/svgRequest.type.ts | 8 -------- 2 files changed, 27 deletions(-) diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts index 1a47b5e..6cea5a6 100644 --- a/src/services/svg.service.ts +++ b/src/services/svg.service.ts @@ -39,23 +39,4 @@ export class SvgService { throw error; } } - - private generateSimpleSvg(data: any, assets: string): string { - return ` - - ${data.username} - Views: ${data.total_views} - Likes: ${data.total_likes} - `; - } - - private generateDefaultSvg(data: any, assets: string, withRank: boolean): string { - return ` - - ${data.username} - Views: ${data.total_views} - Recent Posts: ${data.recent_posts?.length || 0} - ${withRank ? `Rank: #${data.view_rank || 'N/A'}` : ''} - `; - } } diff --git a/src/types/dto/requests/svgRequest.type.ts b/src/types/dto/requests/svgRequest.type.ts index 18a5676..c3d0c96 100644 --- a/src/types/dto/requests/svgRequest.type.ts +++ b/src/types/dto/requests/svgRequest.type.ts @@ -15,14 +15,6 @@ export class GetSvgBadgeQueryDto { @IsEnum(['default', 'simple']) type?: SvgBadgeType; - @IsOptional() - @IsString() - assets?: string; - - @IsOptional() - @IsEnum(['true', 'false']) - withrank?: string; - constructor(type?: SvgBadgeType) { this.type = type || 'default'; } From 764ec4b8a68474c6ee5deab85e9bd222a6ce85f5 Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 00:44:34 +0900 Subject: [PATCH 5/9] =?UTF-8?q?test:=20SVG=20Badge=20API=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/leaderboard.repo.test.ts | 116 ++++++++++++++- src/services/__test__/svg.service.test.ts | 138 ++++++++++++++++++ 2 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 src/services/__test__/svg.service.test.ts diff --git a/src/repositories/__test__/leaderboard.repo.test.ts b/src/repositories/__test__/leaderboard.repo.test.ts index 39801e2..cff5dd1 100644 --- a/src/repositories/__test__/leaderboard.repo.test.ts +++ b/src/repositories/__test__/leaderboard.repo.test.ts @@ -1,5 +1,5 @@ import { Pool } from 'pg'; -import { DBError } from '@/exception'; +import { DBError, NotFoundError } from '@/exception'; import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { mockPool, createMockQueryResult } from '@/utils/fixtures'; @@ -184,4 +184,118 @@ describe('LeaderboardRepository', () => { await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError); }); }); + + describe('getUserStats', () => { + const mockUserStats = { + username: 'test-user', + total_views: '1000', + total_likes: '50', + total_posts: '10', + view_diff: '100', + like_diff: '5', + post_diff: '2', + }; + + it('특정 사용자의 통계를 반환해야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult([mockUserStats])); + + const result = await repo.getUserStats('test-user', 30); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('WHERE u.username = $1'), ['test-user']); + expect(result).toEqual(mockUserStats); + }); + + it('username 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult([mockUserStats])); + + await repo.getUserStats('test-user', 30); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('GROUP BY u.username'), ['test-user']); + }); + + it('사용자가 존재하지 않으면 NotFoundError를 던져야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult([])); + + await expect(repo.getUserStats('non-existent', 30)).rejects.toThrow(NotFoundError); + await expect(repo.getUserStats('non-existent', 30)).rejects.toThrow('사용자를 찾을 수 없습니다: non-existent'); + }); + + it('CTE 쿼리가 포함되어야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult([mockUserStats])); + + await repo.getUserStats('test-user', 30); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('WITH'), expect.anything()); + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('today_stats'), expect.anything()); + }); + + it('쿼리 에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + + await expect(repo.getUserStats('test-user', 30)).rejects.toThrow(DBError); + await expect(repo.getUserStats('test-user', 30)).rejects.toThrow('사용자 통계 조회 중 문제가 발생했습니다.'); + }); + }); + + describe('getRecentPosts', () => { + const mockRecentPosts = [ + { + title: 'Test Post 1', + released_at: '2025-01-01', + today_view: '100', + today_like: '10', + view_diff: '20', + }, + { + title: 'Test Post 2', + released_at: '2025-01-02', + today_view: '200', + today_like: '20', + view_diff: '30', + }, + ]; + + it('특정 사용자의 최근 게시글을 반환해야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult(mockRecentPosts)); + + const result = await repo.getRecentPosts('test-user', 30, 3); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('WHERE u.username = $1'), ['test-user', 3]); + expect(result).toEqual(mockRecentPosts); + }); + + it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult(mockRecentPosts)); + + await repo.getRecentPosts('test-user', 30, 5); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('LIMIT $2'), ['test-user', 5]); + }); + + it('released_at 기준 내림차순 정렬이 포함되어야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult(mockRecentPosts)); + + await repo.getRecentPosts('test-user', 30, 3); + + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('ORDER BY p.released_at DESC'), + expect.anything(), + ); + }); + + it('CTE 쿼리가 포함되어야 한다', async () => { + mockPool.query.mockResolvedValue(createMockQueryResult(mockRecentPosts)); + + await repo.getRecentPosts('test-user', 30, 3); + + expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('WITH'), expect.anything()); + }); + + it('쿼리 에러 발생 시 DBError를 던져야 한다', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection failed')); + + await expect(repo.getRecentPosts('test-user', 30, 3)).rejects.toThrow(DBError); + await expect(repo.getRecentPosts('test-user', 30, 3)).rejects.toThrow('최근 게시글 조회 중 문제가 발생했습니다.'); + }); + }); }); diff --git a/src/services/__test__/svg.service.test.ts b/src/services/__test__/svg.service.test.ts new file mode 100644 index 0000000..5f4d3b1 --- /dev/null +++ b/src/services/__test__/svg.service.test.ts @@ -0,0 +1,138 @@ +import { Pool } from 'pg'; +import { DBError, NotFoundError } from '@/exception'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; + +jest.mock('@/types', () => ({ + BadgeDataResponseDto: jest.fn().mockImplementation((user, recentPosts) => ({ + user, + recentPosts, + })), +})); +jest.mock('@/repositories/leaderboard.repository'); + +import { SvgService } from '@/services/svg.service'; + +describe('SvgService', () => { + let service: SvgService; + let mockRepo: jest.Mocked; + let mockPool: jest.Mocked; + + beforeEach(() => { + const mockPoolObj = {}; + mockPool = mockPoolObj as jest.Mocked; + + const repoInstance = new LeaderboardRepository(mockPool); + mockRepo = repoInstance as jest.Mocked; + + service = new SvgService(mockRepo); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const mockUserStats = { + username: 'test-user', + total_views: '1000', + total_likes: '50', + total_posts: '10', + view_diff: '100', + like_diff: '5', + post_diff: '2', + }; + + const mockRecentPosts = [ + { + title: 'Test Post 1', + released_at: '2025-01-01', + today_view: '100', + today_like: '10', + view_diff: '20', + }, + { + title: 'Test Post 2', + released_at: '2025-01-02', + today_view: '200', + today_like: '20', + view_diff: '30', + }, + ]; + + describe('getBadgeData', () => { + it('type이 default일 때 사용자 통계와 최근 게시글을 반환해야 한다', async () => { + mockRepo.getUserStats.mockResolvedValue(mockUserStats); + mockRepo.getRecentPosts.mockResolvedValue(mockRecentPosts); + + const result = await service.getBadgeData('test-user', 'default'); + + expect(mockRepo.getUserStats).toHaveBeenCalledWith('test-user', 30); + expect(mockRepo.getRecentPosts).toHaveBeenCalledWith('test-user', 30, 3); + expect(result).toHaveProperty('user'); + expect(result).toHaveProperty('recentPosts'); + expect(result.user.username).toBe('test-user'); + expect(result.user.totalViews).toBe(1000); + expect(result.recentPosts).toHaveLength(2); + }); + + it('type이 simple일 때 최근 게시글을 조회하지 않아야 한다', async () => { + mockRepo.getUserStats.mockResolvedValue(mockUserStats); + + const result = await service.getBadgeData('test-user', 'simple'); + + expect(mockRepo.getUserStats).toHaveBeenCalledWith('test-user', 30); + expect(mockRepo.getRecentPosts).not.toHaveBeenCalled(); + expect(result.recentPosts).toHaveLength(0); + }); + + it('문자열 통계 값을 숫자로 변환해야 한다', async () => { + mockRepo.getUserStats.mockResolvedValue(mockUserStats); + mockRepo.getRecentPosts.mockResolvedValue(mockRecentPosts); + + const result = await service.getBadgeData('test-user', 'default'); + + expect(typeof result.user.totalViews).toBe('number'); + expect(typeof result.user.totalLikes).toBe('number'); + expect(typeof result.user.totalPosts).toBe('number'); + expect(typeof result.user.viewDiff).toBe('number'); + expect(typeof result.user.likeDiff).toBe('number'); + expect(typeof result.user.postDiff).toBe('number'); + }); + + it('최근 게시글 데이터를 올바르게 변환해야 한다', async () => { + mockRepo.getUserStats.mockResolvedValue(mockUserStats); + mockRepo.getRecentPosts.mockResolvedValue(mockRecentPosts); + + const result = await service.getBadgeData('test-user', 'default'); + + expect(result.recentPosts[0]).toEqual({ + title: 'Test Post 1', + releasedAt: '2025-01-01', + viewCount: 100, + likeCount: 10, + viewDiff: 20, + }); + }); + + it('dateRange 파라미터가 Repository에 전달되어야 한다', async () => { + mockRepo.getUserStats.mockResolvedValue(mockUserStats); + mockRepo.getRecentPosts.mockResolvedValue([]); + + await service.getBadgeData('test-user', 'default', 7); + + expect(mockRepo.getUserStats).toHaveBeenCalledWith('test-user', 7); + expect(mockRepo.getRecentPosts).toHaveBeenCalledWith('test-user', 7, 3); + }); + + it('Repository에서 NotFoundError 발생 시 그대로 전파해야 한다', async () => { + mockRepo.getUserStats.mockRejectedValue(new NotFoundError('사용자를 찾을 수 없습니다')); + + await expect(service.getBadgeData('non-existent', 'default')).rejects.toThrow(NotFoundError); + }); + + it('Repository에서 DBError 발생 시 그대로 전파해야 한다', async () => { + mockRepo.getUserStats.mockRejectedValue(new DBError('DB 오류')); + + await expect(service.getBadgeData('test-user', 'default')).rejects.toThrow(DBError); + }); + }); +}); From 678e98995b123a7b9fda6a2217998994a2b83d2a Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 01:08:44 +0900 Subject: [PATCH 6/9] =?UTF-8?q?fix:=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=ED=98=95?= =?UTF-8?q?=EC=8B=9D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/svg.controller.ts | 15 ++++++++------- src/services/svg.service.ts | 12 ++++++------ src/types/dto/responses/svgResponse.type.ts | 13 ++++++++----- src/types/index.ts | 2 +- 4 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts index ec395a2..3402032 100644 --- a/src/controllers/svg.controller.ts +++ b/src/controllers/svg.controller.ts @@ -1,14 +1,14 @@ -import { NextFunction, Request, RequestHandler, Response } from "express"; +import { NextFunction, RequestHandler, Request, Response } from "express"; import logger from '@/configs/logger.config' -import { GetSvgBadgeParams, GetSvgBadgeQuery } from "@/types"; +import { GetSvgBadgeQuery, BadgeDataResponseDto } from "@/types"; import { SvgService } from '@/services/svg.service'; export class SvgController { constructor(private svgService: SvgService) {} - getSvgBadge: RequestHandler = async ( - req: Request, - res: Response, + getSvgBadge: RequestHandler<{ username: string }, BadgeDataResponseDto, object, GetSvgBadgeQuery> = async ( + req: Request<{ username: string }, BadgeDataResponseDto, object, GetSvgBadgeQuery>, + res: Response, next: NextFunction, ) => { try { @@ -16,10 +16,11 @@ export class SvgController { const { type = 'default'} = req.query; const data = await this.svgService.getBadgeData(username, type); + const response = new BadgeDataResponseDto(true, '배지 데이터 조회에 성공하였습니다.', data, null); - res.json(data); + res.status(200).json(response); } catch (error) { - logger.error('SVG Badge 생성 실패: ', error); + logger.error('SVG Badge 조회 실패:', error); next(error); } } diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts index 6cea5a6..d25e8bd 100644 --- a/src/services/svg.service.ts +++ b/src/services/svg.service.ts @@ -1,5 +1,5 @@ import logger from '@/configs/logger.config'; -import { BadgeDataResponseDto, SvgBadgeType } from '@/types'; +import { SvgBadgeType, BadgeData } from '@/types'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; export class SvgService { @@ -9,15 +9,15 @@ export class SvgService { username: string, type: SvgBadgeType, dateRange: number = 30, - ): Promise { + ): Promise { try { const userStats = await this.leaderboardRepo.getUserStats(username, dateRange); const recentPosts = type === 'default' ? await this.leaderboardRepo.getRecentPosts(username, dateRange, 3) : []; - return new BadgeDataResponseDto( - { + return { + user: { username: userStats.username, totalViews: Number(userStats.total_views), totalLikes: Number(userStats.total_likes), @@ -26,14 +26,14 @@ export class SvgService { likeDiff: Number(userStats.like_diff), postDiff: Number(userStats.post_diff), }, - recentPosts.map(post => ({ + recentPosts: recentPosts.map(post => ({ title: post.title, releasedAt: post.released_at, viewCount: Number(post.today_view), likeCount: Number(post.today_like), viewDiff: Number(post.view_diff), })) - ) + }; } catch (error) { logger.error('SvgService getBadgeData error: ', error); throw error; diff --git a/src/types/dto/responses/svgResponse.type.ts b/src/types/dto/responses/svgResponse.type.ts index 6486691..1f41e05 100644 --- a/src/types/dto/responses/svgResponse.type.ts +++ b/src/types/dto/responses/svgResponse.type.ts @@ -1,3 +1,5 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + export interface BadgeUserData { username: string; totalViews: number; @@ -16,12 +18,13 @@ export interface BadgeRecentPost { viewDiff: number; } -export class BadgeDataResponseDto { +export interface BadgeData { user: BadgeUserData; recentPosts: BadgeRecentPost[]; +} - constructor(user: BadgeUserData, recentPosts: BadgeRecentPost[]) { - this.user = user; - this.recentPosts = recentPosts; +export class BadgeDataResponseDto extends BaseResponseDto { + constructor(success: boolean, message: string, data: BadgeData | null, error: string | null) { + super(success, message, data, error); } -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index 24ed2f8..fdbd471 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -41,7 +41,7 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse. // SVG 관련 export type { GetSvgBadgeParams, GetSvgBadgeQuery, SvgBadgeType } from '@/types/dto/requests/svgRequest.type'; export { GetSvgBadgeQueryDto } from '@/types/dto/requests/svgRequest.type'; -export type { BadgeUserData, BadgeRecentPost } from '@/types/dto/responses/svgResponse.type'; +export type { BadgeUserData, BadgeRecentPost, BadgeData } from '@/types/dto/responses/svgResponse.type'; export { BadgeDataResponseDto } from '@/types/dto/responses/svgResponse.type'; // Sentry 관련 From 131fc2241e0a1bd60e10bd3a3d5868b29f51bd1a Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 01:14:40 +0900 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20lint=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/svg.controller.ts | 6 +++--- src/routes/svg.router.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts index 3402032..d7a456f 100644 --- a/src/controllers/svg.controller.ts +++ b/src/controllers/svg.controller.ts @@ -6,13 +6,13 @@ import { SvgService } from '@/services/svg.service'; export class SvgController { constructor(private svgService: SvgService) {} - getSvgBadge: RequestHandler<{ username: string }, BadgeDataResponseDto, object, GetSvgBadgeQuery> = async ( - req: Request<{ username: string }, BadgeDataResponseDto, object, GetSvgBadgeQuery>, + getSvgBadge: RequestHandler = async ( + req: Request, res: Response, next: NextFunction, ) => { try { - const { username } = req.params; + const { username } = req.params as { username: string }; const { type = 'default'} = req.query; const data = await this.svgService.getBadgeData(username, type); diff --git a/src/routes/svg.router.ts b/src/routes/svg.router.ts index e01a317..f8bd387 100644 --- a/src/routes/svg.router.ts +++ b/src/routes/svg.router.ts @@ -71,7 +71,7 @@ const svgController = new SvgController(svgService); router.get( '/:username/badge', validateRequestDto(GetSvgBadgeQueryDto, 'query'), - svgController.getSvgBadge as any, + svgController.getSvgBadge, ); export default router; From 4fe15042ac37454770fcf1cc58ae3c83020b5f2f Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 01:21:24 +0900 Subject: [PATCH 8/9] =?UTF-8?q?style:=20prettier=20=ED=8F=AC=EB=A7=B7?= =?UTF-8?q?=ED=8C=85=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/svg.controller.ts | 38 +++++++++--------- src/routes/svg.router.ts | 6 +-- src/services/svg.service.ts | 65 +++++++++++++++---------------- 3 files changed, 52 insertions(+), 57 deletions(-) diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts index d7a456f..4b5a945 100644 --- a/src/controllers/svg.controller.ts +++ b/src/controllers/svg.controller.ts @@ -1,27 +1,27 @@ -import { NextFunction, RequestHandler, Request, Response } from "express"; -import logger from '@/configs/logger.config' -import { GetSvgBadgeQuery, BadgeDataResponseDto } from "@/types"; +import { NextFunction, RequestHandler, Request, Response } from 'express'; +import logger from '@/configs/logger.config'; +import { GetSvgBadgeQuery, BadgeDataResponseDto } from '@/types'; import { SvgService } from '@/services/svg.service'; export class SvgController { - constructor(private svgService: SvgService) {} + constructor(private svgService: SvgService) {} - getSvgBadge: RequestHandler = async ( - req: Request, - res: Response, - next: NextFunction, - ) => { - try { - const { username } = req.params as { username: string }; - const { type = 'default'} = req.query; + getSvgBadge: RequestHandler = async ( + req: Request, + res: Response, + next: NextFunction, + ) => { + try { + const { username } = req.params as { username: string }; + const { type = 'default' } = req.query; - const data = await this.svgService.getBadgeData(username, type); - const response = new BadgeDataResponseDto(true, '배지 데이터 조회에 성공하였습니다.', data, null); + const data = await this.svgService.getBadgeData(username, type); + const response = new BadgeDataResponseDto(true, '배지 데이터 조회에 성공하였습니다.', data, null); - res.status(200).json(response); - } catch (error) { - logger.error('SVG Badge 조회 실패:', error); - next(error); - } + res.status(200).json(response); + } catch (error) { + logger.error('SVG Badge 조회 실패:', error); + next(error); } + }; } diff --git a/src/routes/svg.router.ts b/src/routes/svg.router.ts index f8bd387..62cd752 100644 --- a/src/routes/svg.router.ts +++ b/src/routes/svg.router.ts @@ -68,10 +68,6 @@ const svgController = new SvgController(svgService); * '500': * description: 서버 오류 */ -router.get( - '/:username/badge', - validateRequestDto(GetSvgBadgeQueryDto, 'query'), - svgController.getSvgBadge, -); +router.get('/:username/badge', validateRequestDto(GetSvgBadgeQueryDto, 'query'), svgController.getSvgBadge); export default router; diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts index d25e8bd..a60f4d7 100644 --- a/src/services/svg.service.ts +++ b/src/services/svg.service.ts @@ -2,41 +2,40 @@ import logger from '@/configs/logger.config'; import { SvgBadgeType, BadgeData } from '@/types'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; +const safeNumber = (value: any, defaultValue: number = 0): number => { + const num = Number(value); + return isNaN(num) ? defaultValue : num; +}; + export class SvgService { - constructor(private leaderboardRepo: LeaderboardRepository) {} + constructor(private leaderboardRepo: LeaderboardRepository) {} - async getBadgeData( - username: string, - type: SvgBadgeType, - dateRange: number = 30, - ): Promise { - try { - const userStats = await this.leaderboardRepo.getUserStats(username, dateRange); - const recentPosts = type === 'default' - ? await this.leaderboardRepo.getRecentPosts(username, dateRange, 3) - : []; + async getBadgeData(username: string, type: SvgBadgeType, dateRange: number = 30): Promise { + try { + const userStats = await this.leaderboardRepo.getUserStats(username, dateRange); + const recentPosts = type === 'default' ? await this.leaderboardRepo.getRecentPosts(username, dateRange, 3) : []; - return { - user: { - username: userStats.username, - totalViews: Number(userStats.total_views), - totalLikes: Number(userStats.total_likes), - totalPosts: Number(userStats.total_posts), - viewDiff: Number(userStats.view_diff), - likeDiff: Number(userStats.like_diff), - postDiff: Number(userStats.post_diff), - }, - recentPosts: recentPosts.map(post => ({ - title: post.title, - releasedAt: post.released_at, - viewCount: Number(post.today_view), - likeCount: Number(post.today_like), - viewDiff: Number(post.view_diff), - })) - }; - } catch (error) { - logger.error('SvgService getBadgeData error: ', error); - throw error; - } + return { + user: { + username: userStats.username, + totalViews: safeNumber(userStats.total_views), + totalLikes: safeNumber(userStats.total_likes), + totalPosts: safeNumber(userStats.total_posts), + viewDiff: safeNumber(userStats.view_diff), + likeDiff: safeNumber(userStats.like_diff), + postDiff: safeNumber(userStats.post_diff), + }, + recentPosts: recentPosts.map((post) => ({ + title: post.title, + releasedAt: post.released_at, + viewCount: safeNumber(post.today_view), + likeCount: safeNumber(post.today_like), + viewDiff: safeNumber(post.view_diff), + })), + }; + } catch (error) { + logger.error('SvgService getBadgeData error: ', error); + throw error; } + } } From 9e04ad49f0490b7ac8869baaf654c5df0a92832d Mon Sep 17 00:00:00 2001 From: Jihyun3478 Date: Wed, 17 Dec 2025 01:23:44 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20safeNumber=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EC=9D=98=20=EB=A6=AC=ED=84=B4=20=ED=83=80=EC=9E=85=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/svg.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts index a60f4d7..fbe88a7 100644 --- a/src/services/svg.service.ts +++ b/src/services/svg.service.ts @@ -2,7 +2,7 @@ import logger from '@/configs/logger.config'; import { SvgBadgeType, BadgeData } from '@/types'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -const safeNumber = (value: any, defaultValue: number = 0): number => { +const safeNumber = (value: string | number | null | undefined, defaultValue: number = 0): number => { const num = Number(value); return isNaN(num) ? defaultValue : num; };