Skip to content
Open
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
62 changes: 62 additions & 0 deletions apps/backend/apps/admin/src/polygon/file/file.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common'
import { Prisma, type ToolType } from '@prisma/client'
import type { FileUpload } from 'graphql-upload/processRequest.mjs'
import {
EntityNotExistException,
UnprocessableDataException
} from '@libs/exception'
import { PrismaService } from '@libs/prisma'

const MAX_TOOL_FILE_SIZE = 10 * 1024 * 1024 // 10MB

@Injectable()
export class FileService {
constructor(private readonly prisma: PrismaService) {}

async uploadPolygonToolFile(
problemId: number,
toolType: ToolType,
file: FileUpload
) {
const { filename, createReadStream } = file

//ReadStream → [chunk1, chunk2, chunk3, ...] → Buffer.concat
//→ 최종 Buffer로 변환해 → DB(PostgreSQL)에 저장
const chunks: Buffer[] = []
let total = 0
for await (const chunk of createReadStream()) {
total += chunk.length
if (total > MAX_TOOL_FILE_SIZE) {
throw new UnprocessableDataException('File size exceeds maximum limit')
}
chunks.push(chunk)
}
const fileContent = Buffer.concat(chunks).toString('utf-8')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

파일 내용을 utf-8 문자열로 변환하여 DB에 저장하고 있습니다. PR 설명에 따르면 .cpp 소스코드를 상정하고 있으나, 사용자가 바이너리 파일 등 의도하지 않은 형식의 파일을 업로드할 경우 데이터가 손상될 수 있습니다. 업로드된 파일의 mimetype이나 확장자를 검증하는 로직을 추가하여 의도한 형식의 파일만 처리하도록 하는 것이 안전합니다.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

확장자 검증 추가하겠습니다..!


// (problemId, toolType) unique — 재업로드 시 갱신
const tool = await this.prisma.polygonTool.upsert({
// eslint-disable-next-line @typescript-eslint/naming-convention
where: { problemId_toolType: { problemId, toolType } },
update: { fileName: filename, fileContent },
create: { problemId, toolType, fileName: filename, fileContent }
})
Comment on lines +25 to +42
return tool
}

async deletePolygonFile(problemId: number, toolType: ToolType) {
try {
return await this.prisma.polygonTool.delete({
// eslint-disable-next-line @typescript-eslint/naming-convention
where: { problemId_toolType: { problemId, toolType } }
})
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new EntityNotExistException('PolygonTool')
}
throw error
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface GeneratorRequest {
problemId: number
generatorLanguage: string
generatorCode: string
generatorArgs: string[]
solutionLanguage: string
solutionCode: string
testCaseCount: number
}

export interface ValidatorRequest {
problemId: number
language: string
validatorCode: string
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsBoolean,
IsNumber,
IsString,
ValidateNested
} from 'class-validator'

export class GeneratorJudgeResultDto {
@IsNumber()
generatedTestCases!: number

@IsNumber()
totalTestCases!: number
}

export class GeneratorResultDto {
@IsString()
messageId!: string

@IsNumber()
resultCode!: number

@ValidateNested()
@Type(() => GeneratorJudgeResultDto)
judgeResult!: GeneratorJudgeResultDto

@IsString()
error!: string
}

export class ValidatorTestcaseResultDto {
@IsNumber()
id!: number

@IsBoolean()
isValid!: boolean
}

export class ValidatorJudgeResultDto {
@IsBoolean()
isValid!: boolean

@IsNumber()
testcaseCount!: number

@IsArray()
@ValidateNested({ each: true })
@Type(() => ValidatorTestcaseResultDto)
results!: ValidatorTestcaseResultDto[]
}

export class ValidatorResultDto {
@IsString()
messageId!: string

@IsNumber()
resultCode!: number

@ValidateNested()
@Type(() => ValidatorJudgeResultDto)
judgeResult!: ValidatorJudgeResultDto

@IsString()
error!: string
}
57 changes: 57 additions & 0 deletions apps/backend/apps/admin/src/polygon/polygon-pub.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common'
import { Language, ToolType } from '@prisma/client'
import { PolygonAMQPService } from '@libs/amqp'
import { PrismaService } from '@libs/prisma'

@Injectable()
export class PolygonPublicationService {
constructor(
private readonly prisma: PrismaService,
private readonly amqpService: PolygonAMQPService
) {}
Comment on lines +2 to +11

async publishGeneratorMessage(
problemId: number,
generatorArgs: string[],
testCaseCount: number
) {
//DB에서 generator, solution 조회
const [generator, solution] = await Promise.all([
this.prisma.polygonTool.findUniqueOrThrow({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
problemId_toolType: { problemId, toolType: ToolType.Generator }
}
}),
this.prisma.polygonSolution.findUniqueOrThrow({
where: { problemId }
})
])

//실행 요청 메시지 publish
await this.amqpService.publishGeneratorMessage({
problemId,
generatorLanguage: Language.Cpp,
generatorCode: generator.fileContent,
generatorArgs,
solutionLanguage: solution.language,
solutionCode: solution.fileContent,
testCaseCount
})
}

async publishValidatorMessage(problemId: number) {
const validator = await this.prisma.polygonTool.findUniqueOrThrow({
where: {
// eslint-disable-next-line @typescript-eslint/naming-convention
problemId_toolType: { problemId, toolType: ToolType.Validator }
}
})

await this.amqpService.publishValidatorMessage({
problemId,
language: Language.Cpp,
validatorCode: validator.fileContent
})
Comment on lines +32 to +55
}
}
159 changes: 159 additions & 0 deletions apps/backend/apps/admin/src/polygon/polygon-sub.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common'
import { plainToInstance } from 'class-transformer'
import { validateOrReject, ValidationError } from 'class-validator'
import { Span } from 'nestjs-otel'
import { PolygonAMQPService } from '@libs/amqp'
import { UnprocessableDataException } from '@libs/exception'
import { PrismaService } from '@libs/prisma'
import {
GeneratorResultDto,
ValidatorResultDto
} from './model/polygon-tool-result.dto'

@Injectable()
export class PolygonSubscriptionService implements OnModuleInit {
private readonly logger = new Logger(PolygonSubscriptionService.name)

constructor(
private readonly prisma: PrismaService,
private readonly amqpService: PolygonAMQPService
) {}

onModuleInit() {
this.amqpService.setMessageHandlers({
onGenerateResult: async (msg: object) => {
try {
this.logger.debug(msg, 'Received Polygon Generator Result Message')
const res = await this.validateGeneratorResultMessage(msg)
await this.handleGeneratorResult(res)
} catch (error) {
this.logError(error, 'Unexpected generator result error')
throw error
}
},
onValidateResult: async (msg: object) => {
try {
this.logger.debug(msg, 'Received Polygon Validator Result Message')
const res = await this.validateValidatorResultMessage(msg)
await this.handleValidatorResult(res)
} catch (error) {
this.logError(error, 'Unexpected validator result error')
throw error
}
}
})

this.amqpService.startGeneratorSubscription()
this.amqpService.startValidatorSubscription()
}

/**
* Generator를 실행한 결과 메세지를 class-validator를 통해 검증합니다.
*
* @param msg RabbitMQ에서 전달받은 raw 메세지 객체
* validateOrReject(): DTO 인스턴스가 데코레이터 조건을 만족하면 통과하고, 만족하지 않으면 예외를 던지는 메서드
* @returns 검증을 거친 GeneratorResultDto 객체
*/
@Span()
async validateGeneratorResultMessage(
msg: object
): Promise<GeneratorResultDto> {
const res = plainToInstance(GeneratorResultDto, msg)
await validateOrReject(res, {
whitelist: true,
forbidNonWhitelisted: true
})

return res
}

/**
* Validator를 실행한 결과 메세지를 class-validator를 통해 검증합니다.
*
* @param msg RabbitMQ에서 전달받은 raw 메세지 객체
* validateOrReject(): DTO 인스턴스가 데코레이터 조건을 만족하면 통과하고, 만족하지 않으면 예외를 던지는 메서드
* @returns 검증을 거친 ValidatorResultDto 객체
*/
@Span()
async validateValidatorResultMessage(
msg: object
): Promise<ValidatorResultDto> {
const res = plainToInstance(ValidatorResultDto, msg)
await validateOrReject(res, {
whitelist: true,
forbidNonWhitelisted: true
})

return res
}

@Span()
async handleGeneratorResult(msg: GeneratorResultDto): Promise<void> {
const problemId = Number(msg.messageId)
if (!Number.isInteger(problemId)) {
throw new UnprocessableDataException(
'Invalid generator exercution messageId'
)
}

const lastRunPass = msg.resultCode === 0

await this.prisma.polygonProblem.update({
where: { id: problemId },
data: { lastRunPass }
})

this.logger.log(
{
messageId: msg.messageId,
problemId,
resultCode: msg.resultCode,
lastRunPass,
generatedTestCases: msg.judgeResult.generatedTestCases,
totalTestCases: msg.judgeResult.totalTestCases
},
'Handled Polygon Generator Result Message'
)
}

@Span()
async handleValidatorResult(msg: ValidatorResultDto): Promise<void> {
const problemId = Number(msg.messageId)
if (!Number.isInteger(problemId)) {
throw new UnprocessableDataException(
'Invalid validator execution messageId'
)
}

const lastRunPass = msg.resultCode === 0

await this.prisma.polygonProblem.update({
where: { id: problemId },
data: { lastRunPass }
})

this.logger.log(
{
messageId: msg.messageId,
resultCode: msg.resultCode,
isValid: msg.judgeResult.isValid,
testcaseCount: msg.judgeResult.testcaseCount
},
'Handled Polygon Validator Result Message'
)

// TODO: Validator 실행 결과 수신 후 백엔드 서비스 로직
}

private logError(error: unknown, message: string) {
if (
Array.isArray(error) &&
error.every((e) => e instanceof ValidationError)
) {
this.logger.error(JSON.stringify(error, null, 2), 'Message format error')
return
}

this.logger.error(error, message)
}
}
14 changes: 12 additions & 2 deletions apps/backend/apps/admin/src/polygon/polygon.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { Module } from '@nestjs/common'
import { AMQPModule } from '@libs/amqp'
import { RolesModule } from '@libs/auth'
import { FileService } from './file/file.service'
import { PolygonPublicationService } from './polygon-pub.service'
import { PolygonSubscriptionService } from './polygon-sub.service'
import { PolygonResolver } from './polygon.resolver'
import { PolygonService } from './polygon.service'

@Module({
imports: [RolesModule],
providers: [PolygonResolver, PolygonService]
imports: [RolesModule, AMQPModule],
providers: [
PolygonResolver,
PolygonService,
FileService,
PolygonPublicationService,
PolygonSubscriptionService
]
})
export class PolygonModule {}
Loading
Loading