Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
"@radix-ui",
"^@emotion",
"^@packages",
"^@nestjs",
"^@server-rest",
"^@/api?(s)/(.*)$",
"^@/page?(s)",
"^@/route?(s)/(.*)$",
Expand Down
8 changes: 4 additions & 4 deletions apps/server/rest/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "server-rest",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"type": "commonjs",
"packageManager": "pnpm@10.4.1",
Expand All @@ -27,8 +27,8 @@
"pm2:logs": "pm2 logs",
"kill": "pm2 kill",
"deploy": "pnpm run ci && sh ./.scripts/deploy.sh",
"format": "prettier --write .",
"lint": "eslint ."
"format": "prettier --write src",
"lint": "eslint src"
},
"dependencies": {
"@nestjs/axios": "^4.0.0",
Expand All @@ -42,6 +42,7 @@
"@nestjs/websockets": "^11.1.0",
"@prisma/client": "^6.7.0",
"@supabase/supabase-js": "^2.49.4",
"@99mini/console-logger": "^1.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express": "^4.21.2",
Expand All @@ -55,7 +56,6 @@
"@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/jest": "^29.5.14",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.5",
"jest": "^29.7.0",
Expand Down
24 changes: 20 additions & 4 deletions apps/server/rest/src/common/common.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { HttpModule } from '@nestjs/axios';
import { Global, Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

import { ServerlessProxyService } from './services';
import { ApiModule } from './services/api.module';

import { LoggingInterceptor } from './interceptors';
import { ApiCacheService, GithubApiService, ServerlessProxyService } from './services';

@Global()
@Module({
imports: [HttpModule],
providers: [ServerlessProxyService],
exports: [ServerlessProxyService],
imports: [HttpModule, ApiModule],
providers: [
ServerlessProxyService,
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: 'LOGGING_OPTIONS',
useValue: { logResponse: false },
},
ApiCacheService,
GithubApiService,
],
exports: [ServerlessProxyService, ApiModule],
})
export class CommonModule {}
2 changes: 2 additions & 0 deletions apps/server/rest/src/common/decorators/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './log-route.decorator';
export * from './log-metadata.decorator';
14 changes: 14 additions & 0 deletions apps/server/rest/src/common/decorators/log-metadata.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { SetMetadata } from '@nestjs/common';

export const LOG_METADATA = 'log_metadata';

export interface LogMetadata {
module?: string;
importance?: 'high' | 'medium' | 'low';
}

/**
* 로깅을 위한 메타데이터를 설정하는 데코레이터
* @param metadata 로깅에 사용할 메타데이터
*/
export const LogMetadata = (metadata: LogMetadata) => SetMetadata(LOG_METADATA, metadata);
28 changes: 28 additions & 0 deletions apps/server/rest/src/common/decorators/log-route.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { applyDecorators } from '@nestjs/common';
Copy link

Copilot AI Jun 19, 2025

Choose a reason for hiding this comment

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

The LogRoute decorator is imported in health.controller.ts but never applied to any method. Consider removing the unused decorator or applying it where needed.

Copilot uses AI. Check for mistakes.

import { log } from '@99mini/console-logger';

/**
* 라우트 호출 시 로그를 기록하는 데코레이터
* @param message 추가 메시지 (선택사항)
*/
export function LogRoute(message?: string) {
return applyDecorators((target: any, key: string | symbol, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value;

descriptor.value = function (...args: any[]) {
const className = this.constructor.name;
const methodName = String(key);

if (message) {
log(`[${className}.${methodName}] ${message}`);
} else {
log(`[${className}.${methodName}]`);
}

return originalMethod.apply(this, args);
};

return descriptor;
});
}
2 changes: 2 additions & 0 deletions apps/server/rest/src/common/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './decorators';
export * from './interceptors';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Observable } from 'rxjs';
Copy link

Copilot AI Jun 19, 2025

Choose a reason for hiding this comment

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

The AdvancedLoggingInterceptor is implemented but not registered in CommonModule. Register it via APP_INTERCEPTOR or remove it if unused.

Copilot uses AI. Check for mistakes.
import { tap } from 'rxjs/operators';

import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor, Optional } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

import { log } from '@99mini/console-logger';

import { LOG_METADATA } from '../decorators/log-metadata.decorator';

@Injectable()
export class AdvancedLoggingInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
@Optional() @Inject('LOGGING_OPTIONS') private options?: Record<string, any>,
) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const now = Date.now();

// 컨트롤러와 핸들러에서 메타데이터 가져오기
const handler = context.getHandler();
const controller = context.getClass();
const metadata = this.reflector.getAllAndOverride(LOG_METADATA, [handler, controller]) || {};

// 요청 본문, 쿼리 파라미터, 경로 파라미터 로깅
const body = request.body ? JSON.stringify(request.body) : '';
const query = request.query ? JSON.stringify(request.query) : '';
const params = request.params ? JSON.stringify(request.params) : '';

// 요청 시작 로깅
log(`[start] ${method} ${url}`);
if (Object.keys(metadata).length > 0) {
log(`[metadata] ${JSON.stringify(metadata)}`);
}

if (body && body !== '{}') log(`[body] ${body}`);
if (query && query !== '{}') log(`[query] ${query}`);
if (params && params !== '{}') log(`[params] ${params}`);

return next.handle().pipe(
tap((data) => {
const responseTime = Date.now() - now;

// 응답 데이터 로깅 (선택적)
if (this.options?.logResponse) {
const responseData = data ? JSON.stringify(data).substring(0, 100) : '';
log(`[response] ${responseData}${responseData.length >= 100 ? '...' : ''}`);
}

// 응답 시간 로깅
log(`[end] ${method} ${url} +${responseTime}ms`);
}),
);
}
}
2 changes: 2 additions & 0 deletions apps/server/rest/src/common/interceptors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './logging.interceptor';
export * from './advanced-logging.interceptor';
23 changes: 23 additions & 0 deletions apps/server/rest/src/common/interceptors/logging.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';

import { info } from '@99mini/console-logger';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const method = request.method;
const url = request.url;
const now = Date.now();

return next.handle().pipe(
tap(() => {
const responseTime = Date.now() - now;
info(`${method} ${url} +${responseTime}ms`);
}),
);
}
}
165 changes: 165 additions & 0 deletions apps/server/rest/src/common/services/api-cache.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import NodeCache from 'node-cache';

import { Injectable } from '@nestjs/common';

import { info, error as logError } from '@99mini/console-logger';

export type CacheKey = `github_contributions_${string}` | `wakatime_contributions_${string}`;

@Injectable()
export class ApiCacheService {
private cache: NodeCache;
/**
* 기본 캐시 유효 시간 (초 단위, 1시간)
*/
private readonly DEFAULT_CACHE_TTL = 3_600;
/**
* API별 마지막 요청 시간
*/
private lastRequestTimes: Record<string, number> = {};
/**
* 기본 요청 간 최소 간격 (1초)
*/
private readonly DEFAULT_REQUEST_DELAY = 1_000;

constructor() {
this.cache = new NodeCache({ stdTTL: this.DEFAULT_CACHE_TTL, checkperiod: 600 });
}

/**
* 캐시 설정
* @param key 캐시 키
* @param data 저장할 데이터
* @param ttl 캐시 유효 시간 (초 단위, 기본값: 1시간)
*/
set<T>(key: CacheKey, data: T, ttl?: number): boolean {
return this.cache.set(key, data, ttl || this.DEFAULT_CACHE_TTL);
}

/**
* 캐시에서 데이터 가져오기
* @param key 캐시 키
* @returns 캐시된 데이터 또는 undefined
*/
get<T>(key: CacheKey): T | undefined {
return this.cache.get<T>(key);
}

/**
* 캐시 키가 존재하는지 확인
* @param key 캐시 키
* @returns 존재 여부
*/
has(key: CacheKey): boolean {
return this.cache.has(key);
}

/**
* 캐시 유효 시간 재설정
* @param key 캐시 키
* @param ttl 새로운 유효 시간 (초 단위)
* @returns 성공 여부
*/
ttl(key: CacheKey, ttl: number): boolean {
return this.cache.ttl(key, ttl);
}

/**
* 캐시 키 목록 가져오기
* @returns 캐시 키 배열
*/
keys(): CacheKey[] {
return this.cache.keys() as CacheKey[];
}

/**
* 캐시에서 데이터 삭제
* @param key 캐시 키
* @returns 삭제 성공 여부
*/
del(key: CacheKey): boolean {
return this.cache.del(key) > 0;
}

/**
* API 요청 간 간격을 조절하는 함수
* @param apiKey API 식별자 (여러 API를 구분하기 위한 키)
* @param delay 요청 간 최소 간격 (밀리초, 기본값: 1000ms)
*/
async delayRequest(apiKey: string = 'default', delay?: number): Promise<void> {
const requestDelay = delay || this.DEFAULT_REQUEST_DELAY;
const now = Date.now();
const lastTime = this.lastRequestTimes[apiKey] || 0;
const timeSinceLastRequest = now - lastTime;

if (timeSinceLastRequest < requestDelay) {
const delayTime = requestDelay - timeSinceLastRequest;
await new Promise((resolve) => setTimeout(resolve, delayTime));
}

this.lastRequestTimes[apiKey] = Date.now();
}

/**
* 만료된 캐시 데이터 포함하여 조회 시도
* @param key 캐시 키
* @returns 캐시된 데이터 또는 undefined
*/
getExpired<T>(key: CacheKey): T | undefined {
try {
// 키가 존재하는지 확인
const keys = this.cache.keys();
const foundKey = keys.find((k) => k === key);

if (foundKey) {
const data = this.cache.get<T>(key);
if (data) {
// 캐시 유효 시간 재설정
this.cache.ttl(key, this.DEFAULT_CACHE_TTL);
return data;
}
}
return undefined;
} catch (cacheError: any) {
logError(`캐시 접근 오류: ${cacheError?.message || '알 수 없는 오류'}`);
return undefined;
}
}

/**
* 캐시된 데이터 반환 또는 없을 경우 함수 실행 후 캐싱
* @param options {key: CacheKey, ttl: 캐시 유효 시간 (초), force: 캐시 무시 }
* @param fetchFn 데이터 가져오는 비동기 함수
* @returns 데이터
*/
async getOrFetch<T>(
options: { key: CacheKey; ttl?: number; force?: boolean },
fetchFn: () => Promise<T>,
): Promise<T> {
const { key, ttl, force } = options;
const cachedData = this.get<T>(key);
if (cachedData && !force) {
info(`캐시된 데이터 반환: ${key}`);
return cachedData;
}

try {
// 데이터 가져오기
const data = await fetchFn();

// 결과 캐싱
this.set(key, data, ttl);

return data;
} catch (error: any) {
// 에러 발생 시 만료된 캐시 데이터 확인
const expiredData = this.getExpired<T>(key);
if (expiredData) {
info(`오류 발생으로 만료된 캐시 데이터 반환: ${key}`);
return expiredData;
}

throw error;
}
}
}
10 changes: 10 additions & 0 deletions apps/server/rest/src/common/services/api.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';

import { ApiCacheService } from './api-cache.service';
import { GithubApiService } from './github-api.service';

@Module({
providers: [ApiCacheService, GithubApiService],
exports: [ApiCacheService, GithubApiService],
})
export class ApiModule {}
Loading