Skip to content

Commit

Permalink
Merge pull request #288 from boostcampwm-2024/develop
Browse files Browse the repository at this point in the history
[Merger] 5주차 메인 브랜치 배포
  • Loading branch information
skdltn210 authored Nov 28, 2024
2 parents 7e5b834 + 44d4bb3 commit e2d095e
Show file tree
Hide file tree
Showing 126 changed files with 2,705 additions and 835 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/develop-api-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
branches:
- develop
paths:
- "Backend/src/**"
- "Backend/apps/api/src/**"

jobs:
deploy:
Expand Down
File renamed without changes.
File renamed without changes.
19 changes: 16 additions & 3 deletions Backend/src/app.module.ts → Backend/apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { CategoriesModule } from './categories/categories.module';
import { VideosModule } from './videos/videos.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeOrmConfig } from './config/typeorm.config';
Expand All @@ -15,6 +14,11 @@ import sqliteConfig from './config/sqlite.config';
import mysqlConfig from './config/mysql.config';
import { RedisModule } from '@nestjs-modules/ioredis';
import { redisConfig } from './config/redis.config';
import { WinstonModule } from 'nest-winston';
import { winstonConfig } from './config/logger.config';
import { APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';

@Module({
imports: [
Expand All @@ -32,15 +36,24 @@ import { redisConfig } from './config/redis.config';
inject: [ConfigService],
useFactory: async (configService: ConfigService) => redisConfig(configService),
}),
WinstonModule.forRoot(winstonConfig),
AuthModule,
UsersModule,
CategoriesModule,
VideosModule,
LivesModule,
ChatsModule,
FollowModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: LoggingInterceptor,
},
{
provide: APP_FILTER,
useClass: HttpExceptionFilter,
},],
})
export class AppModule {}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
16 changes: 16 additions & 0 deletions Backend/apps/api/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable, ExecutionContext, UnauthorizedException, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private readonly logger = new Logger(JwtAuthGuard.name);

handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
if (err || !user) {
const request = context.switchToHttp().getRequest();
this.logger.warn(`Unauthorized access attempt: ${request.method} ${request.url}`);
throw err || new UnauthorizedException('Unauthorized');
}
return user;
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CategoryEntity } from './entity/category.entity';
import { LivesModule } from 'src/lives/lives.module';
import { LivesModule } from '../lives/lives.module';

@Module({
imports: [TypeOrmModule.forFeature([CategoryEntity]), LivesModule],
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { VideosController } from './videos.controller';
import { ChatsController } from './chats.controller';

describe('VideosController', () => {
let controller: VideosController;
describe('ChatsController', () => {
let controller: ChatsController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [VideosController],
controllers: [ChatsController],
}).compile();

controller = module.get<VideosController>(VideosController);
controller = module.get<ChatsController>(ChatsController);
});

it('should be defined', () => {
Expand Down
16 changes: 16 additions & 0 deletions Backend/apps/api/src/chats/chats.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Body, Controller, Post, Req, UseGuards, ValidationPipe } from '@nestjs/common';
import { SendChatDto } from './dto/send.chat.dto';
import { ChatsService } from './chats.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { UserEntity } from '../users/entity/user.entity';

@Controller('chats')
export class ChatsController {
constructor(private readonly chatsService: ChatsService) {}

@Post()
@UseGuards(JwtAuthGuard)
async sendChat(@Body(ValidationPipe) sendChatDto: SendChatDto, @Req() req: Request & { user: UserEntity }) {
this.chatsService.ingestChat({ ...sendChatDto, userId: req.user.id, nickname: req.user.nickname });
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ChatsGateway } from './chats.gateway';
import { ChatsService } from './chats.service';
import { RedisModule } from '@nestjs-modules/ioredis';
import { UsersModule } from 'src/users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { ChatsController } from './chats.controller';
import { UsersModule } from '../users/users.module';
import { HttpModule } from '@nestjs/axios';

@Module({
imports: [
Expand All @@ -18,8 +19,10 @@ import { JwtModule } from '@nestjs/jwt';
}),
inject: [ConfigService],
}),
HttpModule,
],
providers: [ChatsGateway, ChatsService],
providers: [ChatsService],
exports: [ChatsService],
controllers: [ChatsController],
})
export class ChatsModule {}
File renamed without changes.
116 changes: 116 additions & 0 deletions Backend/apps/api/src/chats/chats.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { InjectRedis } from '@nestjs-modules/ioredis';
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { UUID } from 'crypto';
import Redis from 'ioredis';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class ChatsService {
constructor(
@InjectRedis() private readonly redisClient: Redis,
private readonly httpService: HttpService,
private readonly configService: ConfigService,
) {}

async ingestChat({
channelId,
message,
userId,
nickname,
}: {
channelId;
message: string;
userId: number;
nickname: string;
}) {
const chatId = crypto.randomUUID();
const chat = {
content: message,
userId,
nickname,
timestamp: new Date(),
channelId,
chatId,
filteringResult: true,
};
const chatString = JSON.stringify(chat);
await this.redisClient.multi().publish(`${channelId}:chat`, chatString).rpush('chatQueue', chatId).exec();
this.clovaFiltering(chat);
}

async readViewers(channelId: UUID) {
return await this.redisClient.hlen(`${channelId}:viewers`);
}

async clearChat(channelId: UUID) {
this.redisClient.del(`${channelId}:chats`);
}

async clovaFiltering(chat) {
const postData = {
messages: [
{
role: 'system',
content: this.configService.get<string>('CLOVA_CHAT_FILTERING_SYSTEM_PROMPT'),
},
{
role: 'user',
content: `채팅내용 : "${chat.content}"`,
},
],
maxTokens: this.configService.get<number>('CLOVA_CHAT_FILTERING_MAX_TOKEN') || 10,
topP: 0.8,
topK: 1,
temperature: 0.1,
repeatPenalty: 1.0,
includeAiFilters: true,
seed: 0,
};
const { data } = await firstValueFrom(
this.httpService.post(this.configService.get<string>('CLOVA_API_URL'), postData, {
headers: {
'X-NCP-CLOVASTUDIO-API-KEY': this.configService.get<string>('CLOVA_API_KEY'),
'X-NCP-APIGW-API-KEY': this.configService.get<string>('CLOVA_API_GATEWAY_KEY'),
'X-NCP-CLOVASTUDIO-REQUEST-ID': this.configService.get<string>('CLOVA_REQUEST_ID'),
},
}),
);

chat.filteringResult = data?.result?.message?.content?.includes('true');
await this.redisClient
.multi()
.publish(
`${chat.channelId}:filter`,
JSON.stringify({ chatId: chat.chatId, filteringResult: chat.filteringResult }),
)
.hset('chatCache', chat.chatId, JSON.stringify(chat))
.exec();
this.flushChat();
}

async flushChat() {
const lockKey = 'chat:flush:lock';
const lock = await this.redisClient.set(lockKey, 'lock', 'NX');

try {
if (lock) {
while (true) {
const frontChatId = await this.redisClient.lindex('chatQueue', 0);
const chatString = await this.redisClient.hget('chatCache', frontChatId);
if (!chatString) {
break;
} else {
const chat = JSON.parse(chatString);
await this.redisClient.multi().rpush(`${chat.channelId}:chats`, chatString).lpop('chatQueue').exec();
}
}
}
} catch (err) {
console.log(err);
} finally {
await this.redisClient.del(lockKey);
}
}
}
11 changes: 11 additions & 0 deletions Backend/apps/api/src/chats/dto/send.chat.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { IsNotEmpty, IsUUID, IsString } from 'class-validator';
import { UUID } from 'crypto';
export class SendChatDto {
@IsUUID()
@IsNotEmpty()
channelId: UUID;

@IsString()
@IsNotEmpty()
message: string;
}
45 changes: 45 additions & 0 deletions Backend/apps/api/src/common/filters/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Inject,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Logger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}

catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();

const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();

const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;

const message =
exception instanceof HttpException
? exception.getResponse()
: exception;

this.logger.error(
`HTTP Status: ${status} Error Message: ${JSON.stringify(message)}`,
);

response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
45 changes: 45 additions & 0 deletions Backend/apps/api/src/common/interceptors/logging.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
Inject,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Request, Response } from 'express';
import { Logger } from 'winston';
import { WINSTON_MODULE_PROVIDER } from 'nest-winston';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
constructor(
@Inject(WINSTON_MODULE_PROVIDER) private readonly logger: Logger,
) {}

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

const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();

const { method, originalUrl } = request;
const headers = { ...request.headers };
delete headers['authorization']; // Authorization 헤더 제외 - 보안상

return next.handle().pipe(
tap(() => {
const statusCode = response.statusCode;
const contentLength = response.get('content-length') || '0';

this.logger.info(
`${method} ${originalUrl} ${statusCode} ${contentLength} - ${
Date.now() - now
}ms`,
{ headers }, // 제외한 헤더를 로그에 포함
);
}),
);
}
}
26 changes: 26 additions & 0 deletions Backend/apps/api/src/config/logger.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as winston from 'winston';
import 'winston-daily-rotate-file';

const logLevel = 'warn';
const logDir = './logs';

export const winstonConfig: winston.LoggerOptions = {
level: logLevel,
format: winston.format.combine(
winston.format.timestamp(),
winston.format.printf(({ timestamp, level, message }) => {
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
}),
),
transports: [
new winston.transports.Console(),
new winston.transports.DailyRotateFile({
dirname: logDir,
filename: 'application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '14d',
}),
],
};
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit e2d095e

Please sign in to comment.