From 569fb04a5bcf6701d5729bb998dfb902f4c9c1f0 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Fri, 3 Apr 2026 05:19:52 +0000 Subject: [PATCH 01/13] feat(be): implement polygon tool upload --- .../admin/src/polygon/file/file.service.ts | 49 +++++++++++++++++++ .../apps/admin/src/polygon/polygon.module.ts | 6 ++- .../admin/src/polygon/polygon.resolver.ts | 28 ++++++++++- .../apps/admin/src/polygon/polygon.service.ts | 35 ++++++++++++- apps/backend/libs/amqp/src/amqp.module.ts | 10 ++-- .../libs/constants/src/rabbitmq.constants.ts | 10 ++++ apps/backend/prisma/schema.prisma | 6 +-- 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 apps/backend/apps/admin/src/polygon/file/file.service.ts diff --git a/apps/backend/apps/admin/src/polygon/file/file.service.ts b/apps/backend/apps/admin/src/polygon/file/file.service.ts new file mode 100644 index 0000000000..4195cda191 --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/file/file.service.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common' +import type { ToolType } from '@prisma/client' +import type { FileUpload } from 'graphql-upload/processRequest.mjs' +import { 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') + + // (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 } + }) + return tool + } + + async deletePolygonFile(problemId: number, toolType: ToolType) { + return await this.prisma.polygonTool.delete({ + // eslint-disable-next-line @typescript-eslint/naming-convention + where: { problemId_toolType: { problemId, toolType } } + }) + } +} diff --git a/apps/backend/apps/admin/src/polygon/polygon.module.ts b/apps/backend/apps/admin/src/polygon/polygon.module.ts index f45388d1c4..4727d5a12b 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.module.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common' +import { AMQPModule } from '@libs/amqp' import { RolesModule } from '@libs/auth' +import { FileService } from './file/file.service' import { PolygonResolver } from './polygon.resolver' import { PolygonService } from './polygon.service' @Module({ - imports: [RolesModule], - providers: [PolygonResolver, PolygonService] + imports: [RolesModule, AMQPModule], + providers: [PolygonResolver, PolygonService, FileService] }) export class PolygonModule {} diff --git a/apps/backend/apps/admin/src/polygon/polygon.resolver.ts b/apps/backend/apps/admin/src/polygon/polygon.resolver.ts index 442be0c0e6..783c013593 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.resolver.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.resolver.ts @@ -1,10 +1,34 @@ -import { Resolver } from '@nestjs/graphql' +import { Args, Int, Mutation, Resolver } from '@nestjs/graphql' +import { ToolType } from '@prisma/client' +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs' +import type { FileUpload } from 'graphql-upload/processRequest.mjs' import { UseDisableAdminGuard } from '@libs/auth' -import { PolygonProblem } from '@admin/@generated' +import { PolygonProblem, PolygonTool } from '@admin/@generated' import { PolygonService } from './polygon.service' @Resolver(() => PolygonProblem) @UseDisableAdminGuard() export class PolygonResolver { constructor(private readonly polygonService: PolygonService) {} + + @Mutation(() => PolygonTool) + async uploadPolygonTool( + @Args('problemId', { type: () => Int }) problemId: number, + @Args('toolType', { type: () => ToolType }) toolType: ToolType, + @Args('file', { type: () => GraphQLUpload }) file: Promise + ) { + return this.polygonService.uploadPolygonTool( + problemId, + toolType, + await file + ) + } + + @Mutation(() => PolygonTool) + async deletePolygonTool( + @Args('problemId', { type: () => Int }) problemId: number, + @Args('toolType', { type: () => ToolType }) toolType: ToolType + ) { + return this.polygonService.deletePolygonTool(problemId, toolType) + } } diff --git a/apps/backend/apps/admin/src/polygon/polygon.service.ts b/apps/backend/apps/admin/src/polygon/polygon.service.ts index 861fcb39aa..b4f5d981cf 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.service.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.service.ts @@ -1,7 +1,40 @@ import { Injectable } from '@nestjs/common' +import { ToolType } from '@prisma/client' +import type { FileUpload } from 'graphql-upload/processRequest.mjs' +import { PolygonAMQPService } from '@libs/amqp' import { PrismaService } from '@libs/prisma' +import { FileService } from './file/file.service' @Injectable() export class PolygonService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly fileService: FileService, + private readonly polygonAMQPService: PolygonAMQPService + ) {} + + async uploadPolygonTool( + problemId: number, + toolType: ToolType, + file: FileUpload + ) { + //DB에 파일 저장 + const uploadedTool = await this.fileService.uploadPolygonToolFile( + problemId, + toolType, + file + ) + + //RabbitMQ 메시지 발행 + await this.polygonAMQPService.publishPolygonToolUploadMessage( + uploadedTool.problemId, + uploadedTool.toolType + ) + + return uploadedTool + } + + async deletePolygonTool(problemId: number, toolType: ToolType) { + return this.fileService.deletePolygonFile(problemId, toolType) + } } diff --git a/apps/backend/libs/amqp/src/amqp.module.ts b/apps/backend/libs/amqp/src/amqp.module.ts index 4191698105..dd44c6232d 100644 --- a/apps/backend/libs/amqp/src/amqp.module.ts +++ b/apps/backend/libs/amqp/src/amqp.module.ts @@ -2,7 +2,11 @@ import { Module } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config' import { RabbitMQModule } from '@golevelup/nestjs-rabbitmq' import { CONSUME_CHANNEL, PUBLISH_CHANNEL } from '@libs/constants' -import { CheckAMQPService, JudgeAMQPService } from './amqp.service' +import { + CheckAMQPService, + JudgeAMQPService, + PolygonAMQPService +} from './amqp.service' @Module({ imports: [ @@ -41,7 +45,7 @@ import { CheckAMQPService, JudgeAMQPService } from './amqp.service' inject: [ConfigService] }) ], - providers: [JudgeAMQPService, CheckAMQPService], - exports: [JudgeAMQPService, CheckAMQPService] + providers: [JudgeAMQPService, CheckAMQPService, PolygonAMQPService], + exports: [JudgeAMQPService, CheckAMQPService, PolygonAMQPService] }) export class AMQPModule {} diff --git a/apps/backend/libs/constants/src/rabbitmq.constants.ts b/apps/backend/libs/constants/src/rabbitmq.constants.ts index be6a0d68b4..2e629e299c 100644 --- a/apps/backend/libs/constants/src/rabbitmq.constants.ts +++ b/apps/backend/libs/constants/src/rabbitmq.constants.ts @@ -33,3 +33,13 @@ export const CHECK_RESULT_KEY = 'check.result' export const CHECK_RESULT_QUEUE = 'plag.q.check.result' export const CHECK_MESSAGE_TYPE = 'check' + +/** + * Polygon Tool 업로드 + */ + +export const POLYGON_EXCHANGE = 'iris.e.direct.polygon' + +export const POLYGON_TOOL_KEY = 'polygon.tool' + +export const POLYGON_TOOL_MESSAGE_TYPE = 'polygonTool' diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 5ebc8002b5..c9ab0738bf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1136,9 +1136,9 @@ model PolygonTool { problem PolygonProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) problemId Int @map("problem_id") - toolType ToolType @map("tool_type") // 'generator' | 'validator' | 'checker' - fileName String @map("file_name") - filePath String @map("file_path") // S3 key + toolType ToolType @map("tool_type") // 'generator' | 'validator' | 'checker' + fileName String @map("file_name") + fileContent String @map("file_content") createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") From 034d3ffcccdb296fcd97cf1ece0edb0b44b8a23d Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 16 Apr 2026 14:59:54 +0000 Subject: [PATCH 02/13] feat(be): implement publication service for polygon to run tool files & update rabbitmq constants --- .../interface/polygonToolRequest.interface.ts | 15 +++++ .../admin/src/polygon/polygon-pub.service.ts | 55 +++++++++++++++++++ .../apps/admin/src/polygon/polygon.service.ts | 37 ++++++++----- .../libs/constants/src/rabbitmq.constants.ts | 17 +++++- 4 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts create mode 100644 apps/backend/apps/admin/src/polygon/polygon-pub.service.ts diff --git a/apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts b/apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts new file mode 100644 index 0000000000..eb75dadca2 --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts @@ -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 +} diff --git a/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts b/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts new file mode 100644 index 0000000000..412f5b415c --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts @@ -0,0 +1,55 @@ +import { Language, ToolType } from '@prisma/client' +import type { PolygonAMQPService } from '@libs/amqp' +import type { PrismaService } from '@libs/prisma' + +export class PolygonPublicationService { + constructor( + private readonly prisma: PrismaService, + private readonly amqpService: PolygonAMQPService + ) {} + + 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 + }) + } +} diff --git a/apps/backend/apps/admin/src/polygon/polygon.service.ts b/apps/backend/apps/admin/src/polygon/polygon.service.ts index b4f5d981cf..66fc88d5d5 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.service.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.service.ts @@ -1,16 +1,16 @@ import { Injectable } from '@nestjs/common' import { ToolType } from '@prisma/client' import type { FileUpload } from 'graphql-upload/processRequest.mjs' -import { PolygonAMQPService } from '@libs/amqp' import { PrismaService } from '@libs/prisma' import { FileService } from './file/file.service' +import { PolygonPublicationService } from './polygon-pub.service' @Injectable() export class PolygonService { constructor( private readonly prisma: PrismaService, private readonly fileService: FileService, - private readonly polygonAMQPService: PolygonAMQPService + private readonly publicationService: PolygonPublicationService ) {} async uploadPolygonTool( @@ -19,22 +19,29 @@ export class PolygonService { file: FileUpload ) { //DB에 파일 저장 - const uploadedTool = await this.fileService.uploadPolygonToolFile( - problemId, - toolType, - file - ) - - //RabbitMQ 메시지 발행 - await this.polygonAMQPService.publishPolygonToolUploadMessage( - uploadedTool.problemId, - uploadedTool.toolType - ) - - return uploadedTool + await this.fileService.uploadPolygonToolFile(problemId, toolType, file) } async deletePolygonTool(problemId: number, toolType: ToolType) { return this.fileService.deletePolygonFile(problemId, toolType) } + + //파일 실행 + async runGenerator( + problemId: number, + generatorArgs: string[], + testCaseCount: number + ) { + await this.publicationService.publishGeneratorMessage( + problemId, + generatorArgs, + testCaseCount + ) + } + + async runValidator(problemId: number) { + await this.publicationService.publishValidatorMessage(problemId) + } + + //테스트케이스 저장 } diff --git a/apps/backend/libs/constants/src/rabbitmq.constants.ts b/apps/backend/libs/constants/src/rabbitmq.constants.ts index 2e629e299c..6810bbb63d 100644 --- a/apps/backend/libs/constants/src/rabbitmq.constants.ts +++ b/apps/backend/libs/constants/src/rabbitmq.constants.ts @@ -35,11 +35,22 @@ export const CHECK_RESULT_QUEUE = 'plag.q.check.result' export const CHECK_MESSAGE_TYPE = 'check' /** - * Polygon Tool 업로드 + * Polygon Tool 업로드 -> Queue */ export const POLYGON_EXCHANGE = 'iris.e.direct.polygon' -export const POLYGON_TOOL_KEY = 'polygon.tool' +export const POLYGON_GENERATOR_KEY = 'polygon.generator' +export const POLYGON_GENERATOR_MESSAGE_TYPE = 'generate' -export const POLYGON_TOOL_MESSAGE_TYPE = 'polygonTool' +export const POLYGON_VALIDATOR_KEY = 'polygon.validator' +export const POLYGON_VALIDATOR_MESSAGE_TYPE = 'validator' + +export const POLYGON_CHECKER_KEY = 'polygon.checker' +export const POLYGON_CHECKER_MESSAGE_TYPE = 'checker' + +export const POLYGON_GENERATOR_RESULT_KEY = 'polygon.generate.result' +export const POLYGON_GENERATOR_RESULT_QUEUE = 'iris.q.polygon.generate.result' + +export const POLYGON_VALIDATOR_RESULT_KEY = 'polygon.validate.result' +export const POLYGON_VALIDATOR_RESULT_QUEUE = 'iris.q.polygon.validate.result' From 71d4d60b0f77cddcebfa70ab88e306bcd7671293 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 16 Apr 2026 15:01:45 +0000 Subject: [PATCH 03/13] fix(be): fix fieldName filePath into fileContent --- apps/backend/prisma/schema.prisma | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index c9ab0738bf..bcfa38e1fa 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -1121,9 +1121,9 @@ model PolygonSolution { problem PolygonProblem @relation(fields: [problemId], references: [id], onDelete: Cascade) problemId Int @unique @map("problem_id") - fileName String @map("file_name") // 원본 파일명 (solution.cpp) - filePath String @map("file_path") // S3 key - language Language + fileName String @map("file_name") // 원본 파일명 (solution.cpp) + fileContent String @map("file_content") //DB 저장 + language Language createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") @@ -1138,7 +1138,7 @@ model PolygonTool { toolType ToolType @map("tool_type") // 'generator' | 'validator' | 'checker' fileName String @map("file_name") - fileContent String @map("file_content") + fileContent String @map("file_content") //DB 저장 createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") From 0c8d86e0965b6e1e35a96e6585584065e8c462a1 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 16 Apr 2026 15:14:17 +0000 Subject: [PATCH 04/13] feat(be): add polygon amqp service logic --- apps/backend/libs/amqp/src/amqp.service.ts | 134 ++++++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/apps/backend/libs/amqp/src/amqp.service.ts b/apps/backend/libs/amqp/src/amqp.service.ts index c0a2ccf07d..11287b698f 100644 --- a/apps/backend/libs/amqp/src/amqp.service.ts +++ b/apps/backend/libs/amqp/src/amqp.service.ts @@ -18,8 +18,21 @@ import { MESSAGE_PRIORITY_HIGH, MESSAGE_PRIORITY_MIDDLE, MESSAGE_PRIORITY_LOW, - SUBMISSION_KEY + SUBMISSION_KEY, + POLYGON_EXCHANGE, + POLYGON_GENERATOR_MESSAGE_TYPE, + POLYGON_GENERATOR_KEY, + POLYGON_GENERATOR_RESULT_KEY, + POLYGON_GENERATOR_RESULT_QUEUE, + POLYGON_VALIDATOR_RESULT_KEY, + POLYGON_VALIDATOR_RESULT_QUEUE, + POLYGON_VALIDATOR_KEY, + POLYGON_VALIDATOR_MESSAGE_TYPE } from '@libs/constants' +import type { + GeneratorRequest, + ValidatorRequest +} from '@admin/polygon/interface/polygonToolRequest.interface' @Injectable() export class JudgeAMQPService { @@ -212,3 +225,122 @@ export class CheckAMQPService { onCheckMessage?: (msg: object) => Promise } } + +@Injectable() +export class PolygonAMQPService { + private readonly logger = new Logger(PolygonAMQPService.name) + + constructor( + private readonly amqpConnection: AmqpConnection, + private readonly traceService: TraceService + ) {} + + //1. 큐 구독 + startGeneratorSubscription() { + //결과메시지 도착하면 콜백 실행됨 + this.amqpConnection.createSubscriber( + //@golevelup/nestjs-rabbitmq 버전이 업데이트 되면서 생긴 문제? + async (msg: object | undefined) => { + try { + if (!msg) return //undefined인 경우 메시지 큐에서 제거 + //onGenerateResult 핸들러가 등록되어 있으면 + if (this.messageHandlers?.onGenerateResult) { + await this.messageHandlers.onGenerateResult(msg) //onGenerateResult() 실행 + } + } catch (error) { + this.logger.error( + error, + 'Unexpected error in handling generator result message' + ) + return new Nack() + } + }, + { + exchange: POLYGON_EXCHANGE, + routingKey: POLYGON_GENERATOR_RESULT_KEY, //결과 큐를 분리할건지 통합할건지 조율해야됨. + queue: POLYGON_GENERATOR_RESULT_QUEUE + }, + ORIGIN_HANDLER_NAME + ) + } + + startValidatorSubscription() { + //결과메시지 도착하면 콜백 실행됨 + this.amqpConnection.createSubscriber( + //@golevelup/nestjs-rabbitmq 버전이 업데이트 되면서 생긴 문제? + async (msg: object | undefined) => { + try { + if (!msg) return //undefined인 경우 메시지 큐에서 제거 + //onValidateResult 핸들러가 등록되어 있으면 + if (this.messageHandlers?.onValidateResult) { + await this.messageHandlers.onValidateResult(msg) //onValidateResult() 실행 + } + } catch (error) { + this.logger.error( + error, + 'Unexpected error in handling validator result message' + ) + return new Nack() + } + }, + { + exchange: POLYGON_EXCHANGE, + routingKey: POLYGON_VALIDATOR_RESULT_KEY, //결과 큐를 분리할건지 통합할건지 조율해야됨. + queue: POLYGON_VALIDATOR_RESULT_QUEUE + }, + ORIGIN_HANDLER_NAME + ) + } + + /** + * Generator 실행 요청을 Iris로 publish합니다. + */ + @Span() + async publishGeneratorMessage(request: GeneratorRequest): Promise { + const span = this.traceService.startSpan('publishGeneratorMessage.publish') + await this.amqpConnection.publish( + POLYGON_EXCHANGE, + POLYGON_GENERATOR_KEY, + request, + { + messageId: `Generator-${request.problemId}`, + persistent: true, + type: POLYGON_GENERATOR_MESSAGE_TYPE + } + ) + span.end() + } + + /** + * Validator 실행 요청을 Iris로 publish합니다. + */ + @Span() + async publishValidatorMessage(request: ValidatorRequest): Promise { + const span = this.traceService.startSpan('publishValidatorMessage.publish') + await this.amqpConnection.publish( + POLYGON_EXCHANGE, + POLYGON_VALIDATOR_KEY, + request, + { + messageId: `Validator-${request.problemId}`, + persistent: true, + type: POLYGON_VALIDATOR_MESSAGE_TYPE, + priority: MESSAGE_PRIORITY_MIDDLE + } + ) + span.end() + } + + //handler 설정 + setMessageHandlers(handlers: { + onGenerateResult?: (msg: object) => Promise + onValidateResult?: (msg: object) => Promise + }) { + this.messageHandlers = handlers + } + + private messageHandlers?: { + onGenerateResult?: (msg: object) => Promise + onValidateResult?: (msg: object) => Promise + } +} From 6a1eca4763e6ce163e49519516609e89735bf12f Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 16 Apr 2026 15:20:33 +0000 Subject: [PATCH 05/13] chore(be): commit migration file --- .../migration.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/backend/prisma/migrations/20260416151922_fix_file_path_into_file_content/migration.sql diff --git a/apps/backend/prisma/migrations/20260416151922_fix_file_path_into_file_content/migration.sql b/apps/backend/prisma/migrations/20260416151922_fix_file_path_into_file_content/migration.sql new file mode 100644 index 0000000000..516080f210 --- /dev/null +++ b/apps/backend/prisma/migrations/20260416151922_fix_file_path_into_file_content/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - You are about to drop the column `file_path` on the `polygon_solution` table. All the data in the column will be lost. + - You are about to drop the column `file_path` on the `polygon_tool` table. All the data in the column will be lost. + - Added the required column `file_content` to the `polygon_solution` table without a default value. This is not possible if the table is not empty. + - Added the required column `file_content` to the `polygon_tool` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."polygon_solution" DROP COLUMN "file_path", +ADD COLUMN "file_content" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "public"."polygon_tool" DROP COLUMN "file_path", +ADD COLUMN "file_content" TEXT NOT NULL; From 66a23466f9c77da1589e21f00ba8bd50d9001009 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Tue, 21 Apr 2026 05:11:32 +0000 Subject: [PATCH 06/13] feat(be): add message result interface --- .../interface/polygonToolResult.interface.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts diff --git a/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts b/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts new file mode 100644 index 0000000000..1079dda9aa --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts @@ -0,0 +1,23 @@ +export interface GeneratorResultMessage { + submissionId: number + resultCode: number + judgeResult: { + generatedTestCases: number + totalTestCases: number + } + error: string +} + +export interface ValidatorResultMessage { + submissionId: number + resultCode: number + judgeResult: { + isValid: boolean + testcaseCount: number + results: Array<{ + id: number + isValid: boolean + }> + } + error: string +} From dc425cf9a8a067a86b1e7e7208627c491303f925 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Wed, 13 May 2026 13:05:40 +0000 Subject: [PATCH 07/13] fix(be): add exception logic for empty file delete --- .../admin/src/polygon/file/file.service.ts | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/file/file.service.ts b/apps/backend/apps/admin/src/polygon/file/file.service.ts index 4195cda191..29913414a4 100644 --- a/apps/backend/apps/admin/src/polygon/file/file.service.ts +++ b/apps/backend/apps/admin/src/polygon/file/file.service.ts @@ -1,7 +1,10 @@ import { Injectable } from '@nestjs/common' -import type { ToolType } from '@prisma/client' +import { Prisma, type ToolType } from '@prisma/client' import type { FileUpload } from 'graphql-upload/processRequest.mjs' -import { UnprocessableDataException } from '@libs/exception' +import { + EntityNotExistException, + UnprocessableDataException +} from '@libs/exception' import { PrismaService } from '@libs/prisma' const MAX_TOOL_FILE_SIZE = 10 * 1024 * 1024 // 10MB @@ -41,9 +44,19 @@ export class FileService { } async deletePolygonFile(problemId: number, toolType: ToolType) { - return await this.prisma.polygonTool.delete({ - // eslint-disable-next-line @typescript-eslint/naming-convention - where: { problemId_toolType: { problemId, 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 + } } } From f0063d782eb8038e53f05231b7c5ff2c0a38870a Mon Sep 17 00:00:00 2001 From: zero1177 Date: Wed, 13 May 2026 13:30:29 +0000 Subject: [PATCH 08/13] fix(be): change message result interface into dto --- .../interface/polygonToolResult.interface.ts | 23 ------- .../polygon/model/polygon-tool-result.dto.ts | 67 +++++++++++++++++++ 2 files changed, 67 insertions(+), 23 deletions(-) delete mode 100644 apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts create mode 100644 apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts diff --git a/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts b/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts deleted file mode 100644 index 1079dda9aa..0000000000 --- a/apps/backend/apps/admin/src/polygon/interface/polygonToolResult.interface.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface GeneratorResultMessage { - submissionId: number - resultCode: number - judgeResult: { - generatedTestCases: number - totalTestCases: number - } - error: string -} - -export interface ValidatorResultMessage { - submissionId: number - resultCode: number - judgeResult: { - isValid: boolean - testcaseCount: number - results: Array<{ - id: number - isValid: boolean - }> - } - error: string -} diff --git a/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts b/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts new file mode 100644 index 0000000000..af8240bfef --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts @@ -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 { + @IsNumber() + submissionId!: number + + @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 { + @IsNumber() + submissionId!: number + + @IsNumber() + resultCode!: number + + @ValidateNested() + @Type(() => ValidatorJudgeResultDto) + judgeResult!: ValidatorJudgeResultDto + + @IsString() + error!: string +} From 7093d11a3f740165aac39251cc4defda7649eaf5 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Wed, 13 May 2026 13:33:15 +0000 Subject: [PATCH 09/13] fix(be): change directory --- .../polygon/{interface => model}/polygonToolRequest.interface.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/backend/apps/admin/src/polygon/{interface => model}/polygonToolRequest.interface.ts (100%) diff --git a/apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts b/apps/backend/apps/admin/src/polygon/model/polygonToolRequest.interface.ts similarity index 100% rename from apps/backend/apps/admin/src/polygon/interface/polygonToolRequest.interface.ts rename to apps/backend/apps/admin/src/polygon/model/polygonToolRequest.interface.ts From bf7520c9bb196576e7a1c2991e21172c981c3acd Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 14 May 2026 07:09:58 +0000 Subject: [PATCH 10/13] fix(be): add @Injectable() annotation --- apps/backend/apps/admin/src/polygon/polygon-pub.service.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts b/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts index 412f5b415c..a463115fbf 100644 --- a/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts +++ b/apps/backend/apps/admin/src/polygon/polygon-pub.service.ts @@ -1,7 +1,9 @@ +import { Injectable } from '@nestjs/common' import { Language, ToolType } from '@prisma/client' -import type { PolygonAMQPService } from '@libs/amqp' -import type { PrismaService } from '@libs/prisma' +import { PolygonAMQPService } from '@libs/amqp' +import { PrismaService } from '@libs/prisma' +@Injectable() export class PolygonPublicationService { constructor( private readonly prisma: PrismaService, From 54bdd3662b2d7c7892ee9d3e92a209382c8ab649 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 14 May 2026 07:10:24 +0000 Subject: [PATCH 11/13] feat(be): implement PolygonSubscriptionService --- .../admin/src/polygon/polygon-sub.service.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 apps/backend/apps/admin/src/polygon/polygon-sub.service.ts diff --git a/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts b/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts new file mode 100644 index 0000000000..cf32fb2223 --- /dev/null +++ b/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts @@ -0,0 +1,112 @@ +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 { + GeneratorResultDto, + ValidatorResultDto +} from './model/polygon-tool-result.dto' + +@Injectable() +export class PolygonSubscriptionService implements OnModuleInit { + private readonly logger = new Logger(PolygonSubscriptionService.name) + + constructor(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() + } + + @Span() + async validateGeneratorResultMessage( + msg: object + ): Promise { + const res = plainToInstance(GeneratorResultDto, msg) + await validateOrReject(res, { + whitelist: true, + forbidNonWhitelisted: true + }) + + return res + } + + @Span() + async validateValidatorResultMessage( + msg: object + ): Promise { + const res = plainToInstance(ValidatorResultDto, msg) + await validateOrReject(res, { + whitelist: true, + forbidNonWhitelisted: true + }) + + return res + } + + @Span() + async handleGeneratorResult(msg: GeneratorResultDto): Promise { + this.logger.log( + { + submissionId: msg.submissionId, + resultCode: msg.resultCode, + generatedTestCases: msg.judgeResult.generatedTestCases, + totalTestCases: msg.judgeResult.totalTestCases + }, + 'Handled Polygon Generator Result Message' + ) + + // TODO: Generator 실행 결과 수신 후 백엔드 서비스 로직 + } + + @Span() + async handleValidatorResult(msg: ValidatorResultDto): Promise { + this.logger.log( + { + submissionId: msg.submissionId, + 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) + } +} From af6e6249fdd337fb96a0180ea9fd9f513ced7d29 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Thu, 14 May 2026 07:12:05 +0000 Subject: [PATCH 12/13] fix(be): fix minor modifications & add providers --- ....interface.ts => polygon-tool-request.interface.ts} | 0 apps/backend/apps/admin/src/polygon/polygon.module.ts | 10 +++++++++- apps/backend/apps/admin/src/polygon/polygon.service.ts | 6 +++++- 3 files changed, 14 insertions(+), 2 deletions(-) rename apps/backend/apps/admin/src/polygon/model/{polygonToolRequest.interface.ts => polygon-tool-request.interface.ts} (100%) diff --git a/apps/backend/apps/admin/src/polygon/model/polygonToolRequest.interface.ts b/apps/backend/apps/admin/src/polygon/model/polygon-tool-request.interface.ts similarity index 100% rename from apps/backend/apps/admin/src/polygon/model/polygonToolRequest.interface.ts rename to apps/backend/apps/admin/src/polygon/model/polygon-tool-request.interface.ts diff --git a/apps/backend/apps/admin/src/polygon/polygon.module.ts b/apps/backend/apps/admin/src/polygon/polygon.module.ts index 4727d5a12b..6afc7e10d1 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.module.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.module.ts @@ -2,11 +2,19 @@ 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, AMQPModule], - providers: [PolygonResolver, PolygonService, FileService] + providers: [ + PolygonResolver, + PolygonService, + FileService, + PolygonPublicationService, + PolygonSubscriptionService + ] }) export class PolygonModule {} diff --git a/apps/backend/apps/admin/src/polygon/polygon.service.ts b/apps/backend/apps/admin/src/polygon/polygon.service.ts index 66fc88d5d5..39c1017b9a 100644 --- a/apps/backend/apps/admin/src/polygon/polygon.service.ts +++ b/apps/backend/apps/admin/src/polygon/polygon.service.ts @@ -19,7 +19,11 @@ export class PolygonService { file: FileUpload ) { //DB에 파일 저장 - await this.fileService.uploadPolygonToolFile(problemId, toolType, file) + return await this.fileService.uploadPolygonToolFile( + problemId, + toolType, + file + ) } async deletePolygonTool(problemId: number, toolType: ToolType) { From 04ea323e701d2715a70baa02f2371c6ac72e9b35 Mon Sep 17 00:00:00 2001 From: zero1177 Date: Fri, 22 May 2026 04:20:57 +0000 Subject: [PATCH 13/13] feat(be): add consuming logic for message result & change name of submissionId --- .../polygon/model/polygon-tool-result.dto.ts | 8 +-- .../admin/src/polygon/polygon-sub.service.ts | 57 +++++++++++++++++-- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts b/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts index af8240bfef..322b349345 100644 --- a/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts +++ b/apps/backend/apps/admin/src/polygon/model/polygon-tool-result.dto.ts @@ -16,8 +16,8 @@ export class GeneratorJudgeResultDto { } export class GeneratorResultDto { - @IsNumber() - submissionId!: number + @IsString() + messageId!: string @IsNumber() resultCode!: number @@ -52,8 +52,8 @@ export class ValidatorJudgeResultDto { } export class ValidatorResultDto { - @IsNumber() - submissionId!: number + @IsString() + messageId!: string @IsNumber() resultCode!: number diff --git a/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts b/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts index cf32fb2223..4a233656fc 100644 --- a/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts +++ b/apps/backend/apps/admin/src/polygon/polygon-sub.service.ts @@ -3,6 +3,8 @@ 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 @@ -12,7 +14,10 @@ import { export class PolygonSubscriptionService implements OnModuleInit { private readonly logger = new Logger(PolygonSubscriptionService.name) - constructor(private readonly amqpService: PolygonAMQPService) {} + constructor( + private readonly prisma: PrismaService, + private readonly amqpService: PolygonAMQPService + ) {} onModuleInit() { this.amqpService.setMessageHandlers({ @@ -42,6 +47,13 @@ export class PolygonSubscriptionService implements OnModuleInit { this.amqpService.startValidatorSubscription() } + /** + * Generator를 실행한 결과 메세지를 class-validator를 통해 검증합니다. + * + * @param msg RabbitMQ에서 전달받은 raw 메세지 객체 + * validateOrReject(): DTO 인스턴스가 데코레이터 조건을 만족하면 통과하고, 만족하지 않으면 예외를 던지는 메서드 + * @returns 검증을 거친 GeneratorResultDto 객체 + */ @Span() async validateGeneratorResultMessage( msg: object @@ -55,6 +67,13 @@ export class PolygonSubscriptionService implements OnModuleInit { return res } + /** + * Validator를 실행한 결과 메세지를 class-validator를 통해 검증합니다. + * + * @param msg RabbitMQ에서 전달받은 raw 메세지 객체 + * validateOrReject(): DTO 인스턴스가 데코레이터 조건을 만족하면 통과하고, 만족하지 않으면 예외를 던지는 메서드 + * @returns 검증을 거친 ValidatorResultDto 객체 + */ @Span() async validateValidatorResultMessage( msg: object @@ -70,24 +89,52 @@ export class PolygonSubscriptionService implements OnModuleInit { @Span() async handleGeneratorResult(msg: GeneratorResultDto): Promise { + 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( { - submissionId: msg.submissionId, + messageId: msg.messageId, + problemId, resultCode: msg.resultCode, + lastRunPass, generatedTestCases: msg.judgeResult.generatedTestCases, totalTestCases: msg.judgeResult.totalTestCases }, 'Handled Polygon Generator Result Message' ) - - // TODO: Generator 실행 결과 수신 후 백엔드 서비스 로직 } @Span() async handleValidatorResult(msg: ValidatorResultDto): Promise { + 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( { - submissionId: msg.submissionId, + messageId: msg.messageId, resultCode: msg.resultCode, isValid: msg.judgeResult.isValid, testcaseCount: msg.judgeResult.testcaseCount