Skip to content
27 changes: 27 additions & 0 deletions src/controllers/svg.controller.ts
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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

타입 안전성 개선이 필요합니다.

라인 3에서 GetSvgBadgeParams 타입을 import하지 않고, 라인 10에서 params 타입을 object로 지정한 후 라인 15에서 타입 단언(as { username: string })을 사용하고 있습니다. 이는 TypeScript의 타입 체크를 우회하며, 라우트가 변경될 경우 런타임 에러가 발생할 수 있습니다.

다음 diff를 적용하여 타입 안전성을 개선하세요:

-import { GetSvgBadgeQuery, BadgeDataResponseDto } from '@/types';
+import { GetSvgBadgeQuery, GetSvgBadgeParams, 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>,
+  getSvgBadge: RequestHandler<GetSvgBadgeParams, BadgeDataResponseDto, object, GetSvgBadgeQuery> = async (
+    req: Request<GetSvgBadgeParams, BadgeDataResponseDto, object, GetSvgBadgeQuery>,
     res: Response<BadgeDataResponseDto>,
     next: NextFunction,
   ) => {
     try {
-      const { username } = req.params as { username: string };
+      const { username } = req.params;
       const { type = 'default' } = req.query;

Also applies to: 9-15

🤖 Prompt for AI Agents
In src/controllers/svg.controller.ts around lines 3 and 9-15, improve TypeScript
safety by importing GetSvgBadgeParams from '@/types' and using it for the route
params instead of typed as object and then using a runtime type assertion;
change the handler signature to accept params: GetSvgBadgeParams, remove the `as
{ username: string }` assertion, and use the typed params.username directly (and
update any other uses in lines 9-15 to rely on the strongly typed property).

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);
}
};
}
116 changes: 115 additions & 1 deletion src/repositories/__test__/leaderboard.repo.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('최근 게시글 조회 중 문제가 발생했습니다.');
});
});
});
70 changes: 69 additions & 1 deletion src/repositories/leaderboard.repository.ts
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';

Expand Down Expand Up @@ -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) 사이라면 전날 데이터를 사용
Expand Down
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;
73 changes: 73 additions & 0 deletions src/routes/svg.router.ts
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:
* 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;
Loading