diff --git a/src/controllers/svg.controller.ts b/src/controllers/svg.controller.ts new file mode 100644 index 0000000..4b5a945 --- /dev/null +++ b/src/controllers/svg.controller.ts @@ -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, + 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); + + res.status(200).json(response); + } catch (error) { + logger.error('SVG Badge 조회 실패:', error); + next(error); + } + }; +} 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/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/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..62cd752 --- /dev/null +++ b/src/routes/svg.router.ts @@ -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: + * 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; 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); + }); + }); +}); diff --git a/src/services/svg.service.ts b/src/services/svg.service.ts new file mode 100644 index 0000000..fbe88a7 --- /dev/null +++ b/src/services/svg.service.ts @@ -0,0 +1,41 @@ +import logger from '@/configs/logger.config'; +import { SvgBadgeType, BadgeData } from '@/types'; +import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; + +const safeNumber = (value: string | number | null | undefined, defaultValue: number = 0): number => { + const num = Number(value); + return isNaN(num) ? defaultValue : num; +}; + +export class SvgService { + 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) : []; + + 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; + } + } +} diff --git a/src/types/dto/requests/svgRequest.type.ts b/src/types/dto/requests/svgRequest.type.ts new file mode 100644 index 0000000..c3d0c96 --- /dev/null +++ b/src/types/dto/requests/svgRequest.type.ts @@ -0,0 +1,21 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; + +export type SvgBadgeType = 'default' | 'simple'; + +export interface GetSvgBadgeParams { + username: string; +} + +export interface GetSvgBadgeQuery { + type?: SvgBadgeType; +} + +export class GetSvgBadgeQueryDto { + @IsOptional() + @IsEnum(['default', 'simple']) + type?: SvgBadgeType; + + constructor(type?: SvgBadgeType) { + this.type = type || 'default'; + } +} diff --git a/src/types/dto/responses/svgResponse.type.ts b/src/types/dto/responses/svgResponse.type.ts new file mode 100644 index 0000000..1f41e05 --- /dev/null +++ b/src/types/dto/responses/svgResponse.type.ts @@ -0,0 +1,30 @@ +import { BaseResponseDto } from '@/types/dto/responses/baseResponse.type'; + +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 interface BadgeData { + user: BadgeUserData; + recentPosts: BadgeRecentPost[]; +} + +export class BadgeDataResponseDto extends BaseResponseDto { + constructor(success: boolean, message: string, data: BadgeData | null, error: string | null) { + super(success, message, data, error); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 5fae7a7..fdbd471 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -38,6 +38,12 @@ 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'; +export type { BadgeUserData, BadgeRecentPost, BadgeData } from '@/types/dto/responses/svgResponse.type'; +export { BadgeDataResponseDto } from '@/types/dto/responses/svgResponse.type'; + // Sentry 관련 export type { SentryIssueStatus } from '@/types/models/Sentry.type'; export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type';