-
Notifications
You must be signed in to change notification settings - Fork 0
[25.12.17 / TASK-257] Feature - SVG Badge API 추가 #47
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ab21edc
1b8086b
ea0407f
ffdc0f0
764ec4b
678e989
131fc22
4fe1504
9e04ad4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| 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) {} | ||
|
|
||
| getSvgBadge: RequestHandler = async ( | ||
| req: Request<object, object, object, GetSvgBadgeQuery>, | ||
| res: Response<BadgeDataResponseDto>, | ||
| 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); | ||
|
|
||
| res.status(200).json(response); | ||
| } catch (error) { | ||
| logger.error('SVG Badge 조회 실패:', error); | ||
| next(error); | ||
| } | ||
| }; | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}`); | ||
| } | ||
|
Comment on lines
+110
to
+112
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이걸 좀 더 앞단에서 하면 어떨까요? |
||
|
|
||
| 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) 사이라면 전날 데이터를 사용 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import pool from '@/configs/db.config'; | ||
| import express, { Router } from 'express'; | ||
| import { LeaderboardRepository } from '@/repositories/leaderboard.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 leaderboardRepository = new LeaderboardRepository(pool); | ||
| const svgService = new SvgService(leaderboardRepository); | ||
| const svgController = new SvgController(svgService); | ||
|
|
||
| /** | ||
| * @swagger | ||
| * /api/{username}/badge: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 프론트에서 사용하는 엔드포인트랑 달라서 통일해야 할 것 같습니다!
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 취소합니다...😅💦💦💦 |
||
| * get: | ||
| * summary: 사용자 배지 데이터 조회 | ||
| * tags: | ||
| * - SVG | ||
| * security: [] | ||
| * parameters: | ||
| * - in: path | ||
| * name: username | ||
| * required: true | ||
| * schema: | ||
| * type: string | ||
| * description: 조회할 사용자명 | ||
| * example: ljh3478 | ||
| * - in: query | ||
| * name: type | ||
| * schema: | ||
| * type: string | ||
| * enum: [default, simple] | ||
| * default: default | ||
| * responses: | ||
| * '200': | ||
| * description: 배지 데이터 조회 성공 | ||
| * content: | ||
| * application/json: | ||
| * schema: | ||
| * 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: 서버 오류 | ||
| */ | ||
| router.get('/:username/badge', validateRequestDto(GetSvgBadgeQueryDto, 'query'), svgController.getSvgBadge); | ||
|
|
||
| export default router; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
타입 안전성 개선이 필요합니다.
라인 3에서
GetSvgBadgeParams타입을 import하지 않고, 라인 10에서 params 타입을object로 지정한 후 라인 15에서 타입 단언(as { username: string })을 사용하고 있습니다. 이는 TypeScript의 타입 체크를 우회하며, 라우트가 변경될 경우 런타임 에러가 발생할 수 있습니다.다음 diff를 적용하여 타입 안전성을 개선하세요:
Also applies to: 9-15
🤖 Prompt for AI Agents