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
Copy link
Member

Choose a reason for hiding this comment

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

getUserStats, getRecentPosts가 리더보드 레포지토리에 있는게 개인적으로 조금 의아한 것 같아요.
이름만 보면 전자는 totalStats에, 후자는 posts에 있어야 할 느낌인데... 그냥 totalStats에 둘 다 넣거나, svg용 리포지토리를 따로 만들어도 괜찮지 않을까 싶고... 애매하네요😅😅 지현님은 어떻게 생각하시나요?
제가 지금 작업하고 있는 통계 새로고침 요청 API는 그냥 totalStats에 위치해있는데 사실 저도 이게 맞는지 모르겠어서 계속 고민중인 부분이에요ㅜ

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}`);
}
Comment on lines +110 to +112
Copy link
Member

Choose a reason for hiding this comment

The 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) 사이라면 전날 데이터를 사용
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:
Copy link
Member

Choose a reason for hiding this comment

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

프론트에서 사용하는 엔드포인트랑 달라서 통일해야 할 것 같습니다!
(cc. @six-standard )

Copy link
Member

Choose a reason for hiding this comment

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

취소합니다...😅💦💦💦
FE 코드 자세히 보니 제가 착각한 것 같네요! 저게 서버 API랑 연동하는 부분인 줄 알았어요ㅎㅎ

* 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