From 0a23cba3d1e694b773b7ff04587ea5ee8c3ed1de Mon Sep 17 00:00:00 2001 From: lukekeum Date: Fri, 31 Jul 2026 11:46:23 +0000 Subject: [PATCH 01/25] feat(be): separate response into package --- apps/iris/src/connector/rabbitmq/connector.go | 5 +++-- .../router/{response.go => response/judge.go} | 20 +++++-------------- apps/iris/src/router/response/response.go | 19 ++++++++++++++++++ apps/iris/src/router/router.go | 7 ++++--- 4 files changed, 31 insertions(+), 20 deletions(-) rename apps/iris/src/router/{response.go => response/judge.go} (79%) create mode 100644 apps/iris/src/router/response/response.go diff --git a/apps/iris/src/connector/rabbitmq/connector.go b/apps/iris/src/connector/rabbitmq/connector.go index 28d61efd55..536d3a3781 100644 --- a/apps/iris/src/connector/rabbitmq/connector.go +++ b/apps/iris/src/connector/rabbitmq/connector.go @@ -8,6 +8,7 @@ import ( amqp "github.com/rabbitmq/amqp091-go" instrumentation "github.com/skkuding/codedang/apps/iris/src" "github.com/skkuding/codedang/apps/iris/src/router" + "github.com/skkuding/codedang/apps/iris/src/router/response" "github.com/skkuding/codedang/apps/iris/src/service/logger" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" @@ -88,10 +89,10 @@ func (c *connector) handle(message amqp.Delivery, ctx context.Context) { resultChan := make(chan []byte) if message.Type == "" { - resultChan <- router.NewResponse("", nil, fmt.Errorf("type(message property) must not be empty")).Marshal() + resultChan <- response.NewJudgeResponse("", nil, fmt.Errorf("type(message property) must not be empty")).Marshal() close(resultChan) } else if message.MessageId == "" { - resultChan <- router.NewResponse("", nil, fmt.Errorf("message_id(message property) must not be empty")).Marshal() + resultChan <- response.NewJudgeResponse("", nil, fmt.Errorf("message_id(message property) must not be empty")).Marshal() close(resultChan) } else { go c.router.Route(message.Type, message.MessageId, message.Body, resultChan, spanCtx) diff --git a/apps/iris/src/router/response.go b/apps/iris/src/router/response/judge.go similarity index 79% rename from apps/iris/src/router/response.go rename to apps/iris/src/router/response/judge.go index 82ec2a596c..50435b541a 100644 --- a/apps/iris/src/router/response.go +++ b/apps/iris/src/router/response/judge.go @@ -1,7 +1,6 @@ -package router +package response import ( - "bytes" "encoding/json" "errors" "strconv" @@ -9,14 +8,14 @@ import ( "github.com/skkuding/codedang/apps/iris/src/handler" ) -type Response struct { +type JudgeResponse struct { SubmissionId int `json:"submissionId"` JudgeResultCode handler.ResultCode `json:"resultCode"` JudgeResult json.RawMessage `json:"judgeResult"` Error string `json:"error"` } -func NewResponse(id string, data json.RawMessage, err error) *Response { +func NewJudgeResponse(id string, data json.RawMessage, err error) *JudgeResponse { resultCode := handler.ACCEPTED errMessage := "" @@ -30,7 +29,7 @@ func NewResponse(id string, data json.RawMessage, err error) *Response { } _id, _ := strconv.Atoi(id) - return &Response{ + return &JudgeResponse{ SubmissionId: _id, JudgeResultCode: resultCode, JudgeResult: data, @@ -38,16 +37,7 @@ func NewResponse(id string, data json.RawMessage, err error) *Response { } } -func JSONMarshal(t interface{}) ([]byte, error) { - // source: https://stackoverflow.com/questions/28595664/how-to-stop-json-marshal-from-escaping-and - buffer := &bytes.Buffer{} - encoder := json.NewEncoder(buffer) - encoder.SetEscapeHTML(false) - err := encoder.Encode(t) - return buffer.Bytes(), err -} - -func (r *Response) Marshal() []byte { +func (r *JudgeResponse) Marshal() []byte { if res, err := JSONMarshal(r); err != nil { // Error on marshaling router response means that diff --git a/apps/iris/src/router/response/response.go b/apps/iris/src/router/response/response.go new file mode 100644 index 0000000000..54b903faa9 --- /dev/null +++ b/apps/iris/src/router/response/response.go @@ -0,0 +1,19 @@ +package response + +import ( + "bytes" + "encoding/json" +) + +type Response interface { + Marshal() []byte +} + +func JSONMarshal(t interface{}) ([]byte, error) { + // source: https://stackoverflow.com/questions/28595664/how-to-stop-json-marshal-from-escaping-and + buffer := &bytes.Buffer{} + encoder := json.NewEncoder(buffer) + encoder.SetEscapeHTML(false) + err := encoder.Encode(t) + return buffer.Bytes(), err +} diff --git a/apps/iris/src/router/router.go b/apps/iris/src/router/router.go index 7f6e5a1595..8f92338950 100644 --- a/apps/iris/src/router/router.go +++ b/apps/iris/src/router/router.go @@ -7,6 +7,7 @@ import ( instrumentation "github.com/skkuding/codedang/apps/iris/src" "github.com/skkuding/codedang/apps/iris/src/handler" + "github.com/skkuding/codedang/apps/iris/src/router/response" "github.com/skkuding/codedang/apps/iris/src/service/logger" "github.com/skkuding/codedang/apps/iris/src/service/testcase" "go.opentelemetry.io/otel" @@ -79,15 +80,15 @@ func (r *router[C, E]) Route(path string, id string, data []byte, out chan []byt default: err := fmt.Errorf("invalid request type: %s", path) r.errHandle(err) - out <- NewResponse(id, nil, err).Marshal() + out <- response.NewJudgeResponse(id, nil, err).Marshal() } for result := range judgeChan { r.errHandle(result.Err) - out <- NewResponse(id, result.Result, result.Err).Marshal() + out <- response.NewJudgeResponse(id, result.Result, result.Err).Marshal() // break } - // return NewResponse(id, handlerResult, err).Marshal() + // return response.NewJudgeResponse(id, handlerResult, err).Marshal() close(out) r.logger.Log(logger.DEBUG, "Router done...") } From dbfd6c997c22a6fb34be0d9755fa0bae7d0ea990 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Sun, 2 Aug 2026 18:38:01 +0000 Subject: [PATCH 02/25] feat(be): add Finished property into JudgeResponse --- apps/iris/src/router/response/judge.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/iris/src/router/response/judge.go b/apps/iris/src/router/response/judge.go index 50435b541a..c7de46182e 100644 --- a/apps/iris/src/router/response/judge.go +++ b/apps/iris/src/router/response/judge.go @@ -12,6 +12,7 @@ type JudgeResponse struct { SubmissionId int `json:"submissionId"` JudgeResultCode handler.ResultCode `json:"resultCode"` JudgeResult json.RawMessage `json:"judgeResult"` + Finished bool `json:"finished"` Error string `json:"error"` } @@ -34,6 +35,7 @@ func NewJudgeResponse(id string, data json.RawMessage, err error) *JudgeResponse JudgeResultCode: resultCode, JudgeResult: data, Error: errMessage, + Finished: false, } } From 66cb1ce68b3e51c5771e9dbdab059ca02e03c6b4 Mon Sep 17 00:00:00 2001 From: qkrrudals886-boop Date: Tue, 4 Aug 2026 13:25:22 +0000 Subject: [PATCH 03/25] feat(be): add finished trigger for judge result completion check --- .gitignore | 3 +++ .../src/submission/class/judger-response.dto.ts | 5 +++++ .../src/submission/submission-sub.service.ts | 16 ++++++++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 7a6c59e511..24a25a831e 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,6 @@ coverage .idea .codex .claude/settings.local.json +docker-compose.override.yml +phase0-* +.devcontainer/devcontainer-lock.json diff --git a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts index c42b515f0d..4c2466b8ad 100644 --- a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts +++ b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts @@ -1,5 +1,6 @@ import { Type } from 'class-transformer' import { + IsBoolean, IsNotEmpty, IsNumber, IsOptional, @@ -35,4 +36,8 @@ export class JudgerResponse { @Type(() => JudgeResult) @IsOptional() judgeResult?: JudgeResult + + @IsBoolean() + @IsOptional() + finished?: boolean } diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index ef0b78b89d..07a7a92b27 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -288,7 +288,8 @@ export class SubmissionSubscriptionService implements OnModuleInit { result: status, cpuTime: BigInt(msg.judgeResult.cpuTime), memoryUsage: msg.judgeResult.memory, - output: msg.judgeResult.output + output: msg.judgeResult.output, + finished: msg.finished } await this.updateTestcaseJudgeResult(submissionResult) @@ -353,7 +354,12 @@ export class SubmissionSubscriptionService implements OnModuleInit { @Span() async updateTestcaseJudgeResult( submissionResult: Partial & - Pick + Pick< + SubmissionResult, + 'result' | 'submissionId' | 'problemTestcaseId' + > & { + finished?: boolean + } ): Promise { await this.prisma.submissionResult.update({ where: { @@ -382,13 +388,15 @@ export class SubmissionSubscriptionService implements OnModuleInit { (result) => result !== submissionResult.result ) ) { - this.updateTestcaseStats( + await this.updateTestcaseStats( submissionResult.problemTestcaseId, submissionResult.result === ResultStatus.Accepted ) } - await this.updateSubmissionResult(submissionResult.submissionId) + if (submissionResult.finished) { + await this.updateSubmissionResult(submissionResult.submissionId) + } } /** From 87b6878623e3602a4ff1183ad0d05f11f7cfcaa1 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Tue, 4 Aug 2026 23:50:12 +0000 Subject: [PATCH 04/25] feat(be): add submission response dto --- .../src/submission/class/judger-response.dto.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts index 4c2466b8ad..5462b9b4ef 100644 --- a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts +++ b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts @@ -6,7 +6,8 @@ import { IsOptional, IsString, Max, - Min + Min, + ValidateNested } from 'class-validator' class JudgeResult { @@ -41,3 +42,13 @@ export class JudgerResponse { @IsOptional() finished?: boolean } + +export class SubmissionResponse { + @IsNumber() + @IsNotEmpty() + submissionId: number + + @ValidateNested({ each: true }) + @Type(() => JudgerResponse) + judgeResults: JudgerResponse[] +} From 5c53f64f839071263632a4499e666abe03c97561 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Tue, 4 Aug 2026 23:54:12 +0000 Subject: [PATCH 05/25] fix(be): change judger handler type to submission response --- .../src/submission/submission-sub.service.ts | 168 +++++++++++------- 1 file changed, 99 insertions(+), 69 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 07a7a92b27..f696c113ac 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -25,7 +25,7 @@ import { } from '@libs/constants' import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' -import { JudgerResponse } from './class/judger-response.dto' +import { JudgerResponse, SubmissionResponse } from './class/judger-response.dto' @Injectable() export class SubmissionSubscriptionService implements OnModuleInit { @@ -43,6 +43,9 @@ export class SubmissionSubscriptionService implements OnModuleInit { onRunMessage: async (msg: object, isUserTest: boolean) => { try { const res = await this.validateJudgerResponse(msg) + if (res instanceof SubmissionResponse) { + return // Ack + } await this.handleRunMessage(res, res.submissionId, isUserTest) } catch (error) { if ( @@ -62,8 +65,12 @@ export class SubmissionSubscriptionService implements OnModuleInit { try { const res = await this.validateJudgerResponse(msg) - const isOudated = await this.isOutdatedTestcase(res) - if (isOudated) return + // TODO: Change to SubmissionReponse + if (res instanceof JudgerResponse) { + const isOudated = await this.isOutdatedTestcase(res) + if (isOudated) return + return + } await this.handleJudgerMessage(res) } catch (error) { @@ -216,7 +223,16 @@ export class SubmissionSubscriptionService implements OnModuleInit { * @throws {ValidationError[]} 유효성 검사 실패 시 발생 */ @Span() - async validateJudgerResponse(msg: object): Promise { + async validateJudgerResponse( + msg: object + ): Promise { + const isSubmissionResult = Boolean(msg['finished']) + if (isSubmissionResult) { + const res: SubmissionResponse = plainToInstance(SubmissionResponse, msg) + await validateOrReject(res) + + return res + } const res: JudgerResponse = plainToInstance(JudgerResponse, msg) await validateOrReject(res) @@ -253,7 +269,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { } /** - * 채점 서버로부터 수신한 개별 테스트케이스의 채점 결과 메시지를 처리합니다. + * 채점 서버로부터 수신한 채점 결과 메시지를 처리합니다. * * 1. 메시지의 상태 코드(`resultCode`)를 파싱하여 `ResultStatus`를 결정합니다. * 2. 에러 상태(ServerError, CompileError)인 경우, `handleJudgeError`를 호출하여 예외 처리를 수행하고 종료합니다. @@ -265,34 +281,47 @@ export class SubmissionSubscriptionService implements OnModuleInit { * @throws {UnprocessableDataException} 정상 결과(`judgeResult`)가 누락된 경우 예외 발생 */ @Span() - async handleJudgerMessage(msg: JudgerResponse): Promise { - const status = Status(msg.resultCode) + async handleJudgerMessage(msg: SubmissionResponse): Promise { + const submissionResults: { + submissionId: number + problemTestcaseId: number + result: ResultStatus + cpuTime: bigint + memoryUsage: number + output: string | undefined + }[] = [] + + msg.judgeResults.map(async (value) => { + const status = Status(value.resultCode) + + if ( + status === ResultStatus.ServerError || + status === ResultStatus.CompileError + ) { + await this.handleJudgeError(status, value) + return + } - if ( - status === ResultStatus.ServerError || - status === ResultStatus.CompileError - ) { - await this.handleJudgeError(status, msg) - return - } + if (!value.judgeResult) { + throw new UnprocessableDataException( + 'JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response' + ) + } - if (!msg.judgeResult) { - throw new UnprocessableDataException( - 'JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response' - ) - } + const submissionResult = { + submissionId: value.submissionId, + problemTestcaseId: value.judgeResult.testcaseId, + result: status, + cpuTime: BigInt(value.judgeResult.cpuTime), + memoryUsage: value.judgeResult.memory, + output: value.judgeResult.output, + finished: value.finished! + } - const submissionResult = { - submissionId: msg.submissionId, - problemTestcaseId: msg.judgeResult.testcaseId, - result: status, - cpuTime: BigInt(msg.judgeResult.cpuTime), - memoryUsage: msg.judgeResult.memory, - output: msg.judgeResult.output, - finished: msg.finished - } + submissionResults.push(submissionResult) + }) - await this.updateTestcaseJudgeResult(submissionResult) + await this.updateTestcaseJudgeResult(submissionResults) } /** @@ -351,53 +380,54 @@ export class SubmissionSubscriptionService implements OnModuleInit { * - 업데이트할 테스트케이스 결과 데이터 (필수: result, submissionId, problemTestcaseId) * @returns {Promise} * */ + // TODO: @Span() async updateTestcaseJudgeResult( - submissionResult: Partial & + submissionResults: (Partial & Pick< SubmissionResult, 'result' | 'submissionId' | 'problemTestcaseId' > & { finished?: boolean - } - ): Promise { - await this.prisma.submissionResult.update({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - submissionId_problemTestcaseId: { - submissionId: submissionResult.submissionId, - problemTestcaseId: submissionResult.problemTestcaseId - } - }, - data: { - result: submissionResult.result, - cpuTime: submissionResult.cpuTime, - memoryUsage: submissionResult.memoryUsage, - output: submissionResult.output - } - }) - - const invalidSubmissionStatuses: Array = [ - ResultStatus.Judging, - ResultStatus.ServerError, - ResultStatus.Blind, - ResultStatus.Canceled - ] - if ( - invalidSubmissionStatuses.every( - (result) => result !== submissionResult.result - ) - ) { - await this.updateTestcaseStats( - submissionResult.problemTestcaseId, - submissionResult.result === ResultStatus.Accepted - ) - } - - if (submissionResult.finished) { - await this.updateSubmissionResult(submissionResult.submissionId) - } - } + })[] + ): Promise {} + // await this.prisma.submissionResult.updateMany({ + // where: { + // // eslint-disable-next-line @typescript-eslint/naming-convention + // submissionId_problemTestcaseId: { + // submissionId: submissionResult.submissionId, + // problemTestcaseId: submissionResult.problemTestcaseId + // } + // }, + // data: { + // result: submissionResult.result, + // cpuTime: submissionResult.cpuTime, + // memoryUsage: submissionResult.memoryUsage, + // output: submissionResult.output + // } + // }) + + // const invalidSubmissionStatuses: Array = [ + // ResultStatus.Judging, + // ResultStatus.ServerError, + // ResultStatus.Blind, + // ResultStatus.Canceled + // ] + // if ( + // invalidSubmissionStatuses.every( + // (result) => result !== submissionResult.result + // ) + // ) { + // await this.updateTestcaseStats( + // submissionResult.problemTestcaseId, + // submissionResult.result === ResultStatus.Accepted + // ) + // } + + // if (submissionResult.finished) { + // await this.updateSubmissionResult(submissionResult.submissionId) + // } + // } /** * 개별 테스트케이스의 실행 통계를 업데이트합니다. From a31594d16a97e6ab37e21d577bead716a279318a Mon Sep 17 00:00:00 2001 From: lukekeum Date: Tue, 4 Aug 2026 23:58:22 +0000 Subject: [PATCH 06/25] chore(be): add TODO comment --- .../apps/client/src/submission/submission-sub.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index f696c113ac..9415776a69 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -380,7 +380,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { * - 업데이트할 테스트케이스 결과 데이터 (필수: result, submissionId, problemTestcaseId) * @returns {Promise} * */ - // TODO: + // TODO: Change update query to prisma.$transaction @Span() async updateTestcaseJudgeResult( submissionResults: (Partial & @@ -391,7 +391,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { finished?: boolean })[] ): Promise {} - // await this.prisma.submissionResult.updateMany({ + // await this.prisma.submissionResult.update({ // where: { // // eslint-disable-next-line @typescript-eslint/naming-convention // submissionId_problemTestcaseId: { From d82265b4c9a2fb98993e0877f35e1e3dd04b648e Mon Sep 17 00:00:00 2001 From: lukekeum Date: Wed, 5 Aug 2026 00:01:55 +0000 Subject: [PATCH 07/25] feat(be): add submission response struct --- apps/iris/src/router/response/submission.go | 37 +++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 apps/iris/src/router/response/submission.go diff --git a/apps/iris/src/router/response/submission.go b/apps/iris/src/router/response/submission.go new file mode 100644 index 0000000000..80e4011858 --- /dev/null +++ b/apps/iris/src/router/response/submission.go @@ -0,0 +1,37 @@ +package response + +import ( + "strconv" +) + +type SubmissionResponse struct { + SubmissionId int `json:"submissionId"` + JudgeResults []*JudgeResponse `json:"judgeResults"` + Finished bool `json:"finished"` +} + +func NewSubmissionResponse(id string, judgeResponses []*JudgeResponse) *SubmissionResponse { + // TODO: JudgeResponse 가공하기 + + _id, _ := strconv.Atoi(id) + return &SubmissionResponse{ + SubmissionId: _id, + JudgeResults: judgeResponses, + Finished: true, + } +} + +func (r *SubmissionResponse) formatJudgeResponse(res *JudgeResponse) {} + +func (r *SubmissionResponse) Marshal() []byte { + + if res, err := JSONMarshal(r); err != nil { + // Error on marshaling router response means that + // the process cannot send valid response data + // because some logic is incorrect. + // So, panic without recover because debugging is needed + panic(err) + } else { + return res + } +} From fdc6a234d75fea49c62c0e0296eb5f700c7889b1 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Wed, 5 Aug 2026 00:02:55 +0000 Subject: [PATCH 08/25] feat(be): add submission response send logic --- apps/iris/src/router/router.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/iris/src/router/router.go b/apps/iris/src/router/router.go index 8f92338950..76b5811c36 100644 --- a/apps/iris/src/router/router.go +++ b/apps/iris/src/router/router.go @@ -83,11 +83,18 @@ func (r *router[C, E]) Route(path string, id string, data []byte, out chan []byt out <- response.NewJudgeResponse(id, nil, err).Marshal() } + var judgeResult []*response.JudgeResponse + for result := range judgeChan { r.errHandle(result.Err) - out <- response.NewJudgeResponse(id, result.Result, result.Err).Marshal() + res := response.NewJudgeResponse(id, result.Result, result.Err) + out <- res.Marshal() + judgeResult = append(judgeResult, res) // break } + + out <- response.NewSubmissionResponse(id, judgeResult).Marshal() + // return response.NewJudgeResponse(id, handlerResult, err).Marshal() close(out) r.logger.Log(logger.DEBUG, "Router done...") From 303ef6ea85cc577de329f0c2e99987f40c39c4a5 Mon Sep 17 00:00:00 2001 From: qkrrudals886-boop Date: Wed, 5 Aug 2026 09:28:06 +0000 Subject: [PATCH 09/25] chore(be): remove dead code from batch update refactor --- .../src/submission/submission-sub.service.ts | 132 +++++++++--------- 1 file changed, 63 insertions(+), 69 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 9415776a69..4a06986ddf 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -373,14 +373,13 @@ export class SubmissionSubscriptionService implements OnModuleInit { * 개별 테스트케이스의 채점 결과를 DB에 반영하고, 후속 처리를 수행합니다. * * 1. `SubmissionResult` 테이블에 해당 테스트케이스의 채점 결과(성공 여부, 시간, 메모리, 출력 등)를 업데이트합니다. - * 2. 유효한 채점 결과(Judging, ServerError 등이 아닌 확정된 상태)라면, `updateTestcaseStats`를 호출하여 테스트케이스별 통계를 갱신합니다. + * 2. 유효한 채점 결과(Judging, ServerError 등이 아닌 확정된 상태)라면, 테스트케이스별 통계를 갱신합니다. * 3. `updateSubmissionResult`를 호출하여, 해당 제출(Submission)의 전체 채점 완료 여부를 확인하고 최종 결과를 갱신합니다. * * @param {Partial & Pick} submissionResult * - 업데이트할 테스트케이스 결과 데이터 (필수: result, submissionId, problemTestcaseId) * @returns {Promise} * */ - // TODO: Change update query to prisma.$transaction @Span() async updateTestcaseJudgeResult( submissionResults: (Partial & @@ -390,75 +389,70 @@ export class SubmissionSubscriptionService implements OnModuleInit { > & { finished?: boolean })[] - ): Promise {} - // await this.prisma.submissionResult.update({ - // where: { - // // eslint-disable-next-line @typescript-eslint/naming-convention - // submissionId_problemTestcaseId: { - // submissionId: submissionResult.submissionId, - // problemTestcaseId: submissionResult.problemTestcaseId - // } - // }, - // data: { - // result: submissionResult.result, - // cpuTime: submissionResult.cpuTime, - // memoryUsage: submissionResult.memoryUsage, - // output: submissionResult.output - // } - // }) - - // const invalidSubmissionStatuses: Array = [ - // ResultStatus.Judging, - // ResultStatus.ServerError, - // ResultStatus.Blind, - // ResultStatus.Canceled - // ] - // if ( - // invalidSubmissionStatuses.every( - // (result) => result !== submissionResult.result - // ) - // ) { - // await this.updateTestcaseStats( - // submissionResult.problemTestcaseId, - // submissionResult.result === ResultStatus.Accepted - // ) - // } - - // if (submissionResult.finished) { - // await this.updateSubmissionResult(submissionResult.submissionId) - // } - // } - - /** - * 개별 테스트케이스의 실행 통계를 업데이트합니다. - * - * 매 실행 시마다 `submissionCount`를 1씩 증가시키며, - * 결과가 `Accepted`인 경우 `acceptedCount`도 1씩 증가시킵니다. - * - * @param {number} testcaseId 통계를 업데이트할 테스트케이스 ID - * @param {boolean} isAccepted 채점 결과가 정답(Accepted)인지 여부 - * @returns {Promise} - */ - @Span() - async updateTestcaseStats( - testcaseId: number, - isAccepted: boolean ): Promise { - const testcaseStats = { - where: { - id: testcaseId - }, - data: { - submissionCount: { - increment: 1 - }, - acceptedCount: { - increment: isAccepted ? 1 : 0 - } - } - } + if (submissionResults.length === 0) return + + const submissionId = submissionResults[0].submissionId + + const invalidSubmissionStatuses: Array = [ + ResultStatus.Judging, + ResultStatus.ServerError, + ResultStatus.Blind, + ResultStatus.Canceled + ] + + const statsTargets = submissionResults.filter( + (submissionResult) => + !invalidSubmissionStatuses.includes(submissionResult.result) + ) - await this.prisma.problemTestcase.update(testcaseStats) + await this.prisma.$transaction([ + this.prisma.$executeRaw` + UPDATE "submission_result" AS sr + SET "result" = v.result::"ResultStatus", + "cpu_time" = v.cpu_time, + "memory_usage" = v.memory_usage, + "output" = v.output, + "update_time" = NOW() + FROM ( + VALUES ${Prisma.join( + submissionResults.map( + (r) => Prisma.sql`( + ${r.problemTestcaseId}::int, + ${r.result}::text, + ${r.cpuTime ?? null}::bigint, + ${r.memoryUsage ?? null}::int, + ${r.output ?? null}::text + )` + ) + )} + ) AS v(problem_test_case_id, result, cpu_time, memory_usage, output) + WHERE sr."submission_id" = ${submissionId} + AND sr."problem_test_case_id" = v.problem_test_case_id; + `, + ...(statsTargets.length > 0 + ? [ + this.prisma.$executeRaw` + UPDATE "problem_testcase" as pt + SET "submission_count" = pt."submission_count" + 1, + "accepted_count" = pt."accepted_count" + v.accepted + FROM ( + VALUES ${Prisma.join( + statsTargets.map( + (r) => Prisma.sql`( + ${r.problemTestcaseId}::int, + ${r.result === ResultStatus.Accepted ? 1 : 0}::int + )` + ) + )} + ) AS v(problem_test_case_id, accepted) + WHERE pt."id" = v.problem_test_case_id + ` + ] + : []) + ]) + + await this.updateSubmissionResult(submissionId) } /** From 6a59540bbb4050692bd44261dd0adec20eabd5e7 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Sun, 9 Aug 2026 02:22:56 +0000 Subject: [PATCH 10/25] fix(be): replace outdated check with testcase filtering --- .../src/submission/submission-sub.service.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 4a06986ddf..cdf48e3f66 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -65,13 +65,18 @@ export class SubmissionSubscriptionService implements OnModuleInit { try { const res = await this.validateJudgerResponse(msg) - // TODO: Change to SubmissionReponse if (res instanceof JudgerResponse) { - const isOudated = await this.isOutdatedTestcase(res) - if (isOudated) return - return + // JudgerResponse 메시지는 처리하지 않습니다. + return // Ack } + const validResponse = await this.filterOutdatedTestcases( + res.submissionId, + res.judgeResults + ) + + res.judgeResults = validResponse + await this.handleJudgerMessage(res) } catch (error) { if ( @@ -227,6 +232,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { msg: object ): Promise { const isSubmissionResult = Boolean(msg['finished']) + console.log(isSubmissionResult) if (isSubmissionResult) { const res: SubmissionResponse = plainToInstance(SubmissionResponse, msg) await validateOrReject(res) @@ -240,32 +246,46 @@ export class SubmissionSubscriptionService implements OnModuleInit { } /** - * 채점 결과가 도착한 테스트케이스가 최신 상태인지(유효한지) 확인합니다. + * 도착한 테스트케이스들이 최신 상태인지(유효한지) 확인합니다. * * 문제 출제자가 테스트케이스를 수정하거나 새로 업로드하면(`uploadTestcaseZip` 등), * 기존 테스트케이스들은 모두 `isOutdated: true`로 설정됩니다. * * 1. 응답에 포함된 `testcaseId`가 현재 유효한지(`isOutdated: false`) 확인합니다. - * 2. 해당 테스트케이스가 존재하지 않으면(즉, Outdated 되었거나 삭제된 경우), `true`를 반환합니다. + * 2. 해당 테스트케이스가 존재하지 않으면(즉, Outdated 되었거나 삭제된 경우), 반환값에서 제외합니다. * - * @param {JudgerResponse} res 채점 서버로부터 수신한 응답 메시지 객체 - * @returns {Promise} 테스트케이스가 만료(Outdated)되었으면 `true`, 유효하면 `false` + * @param {number} submissionId 보내진 응답의 제출 ID + * @param {JudgerResponse[]} res 채점 서버로부터 수신한 채점 결과 배열 + * @returns {Promise} 유효한 채점 결과만 담은 배열 */ @Span() - async isOutdatedTestcase(res: JudgerResponse): Promise { - const testcase = await this.prisma.problemTestcase.count({ + async filterOutdatedTestcases( + submissionId: number, + res: JudgerResponse[] + ): Promise { + const testCaseIds = res + .map((v) => v.judgeResult?.testcaseId) + .filter((v) => v !== undefined) + + const validTestcases = await this.prisma.problemTestcase.findMany({ + select: { id: true }, where: { - id: res.judgeResult?.testcaseId, + id: { in: testCaseIds }, isOutdated: false, problem: { submission: { - some: { id: res.submissionId } + some: { id: submissionId } } } } }) - return testcase === 0 + const validIds = new Set(validTestcases.map((v) => v.id)) + + return res.filter((v) => { + const id = v.judgeResult?.testcaseId + return id !== undefined && validIds.has(id) + }) } /** From 4f3c8848fff98cfd4cbc3e37d0b96d17a4501dd0 Mon Sep 17 00:00:00 2001 From: qkrrudals886-boop Date: Mon, 10 Aug 2026 13:32:48 +0000 Subject: [PATCH 11/25] test(be): add tests for batch update handleJudgerMessage/updateTestcaseJudgeResult --- .../src/submission/submission-sub.service.ts | 6 +- .../test/submission-sub.service.spec.ts | 153 ++++++++++++------ 2 files changed, 111 insertions(+), 48 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index cdf48e3f66..63db9c3997 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -311,7 +311,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { output: string | undefined }[] = [] - msg.judgeResults.map(async (value) => { + for (const value of msg.judgeResults) { const status = Status(value.resultCode) if ( @@ -324,7 +324,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { if (!value.judgeResult) { throw new UnprocessableDataException( - 'JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response' + `JudgeResult is missing for submission ${msg.submissionId} - cannot process judge response` ) } @@ -339,7 +339,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { } submissionResults.push(submissionResult) - }) + } await this.updateTestcaseJudgeResult(submissionResults) } diff --git a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts index da3fdcedee..2ab74baf4a 100644 --- a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts @@ -39,7 +39,14 @@ const msg = { resultCode: 1, submissionId: 1, error: '', - judgeResult + judgeResult, + finished: false +} + +const submissionResponseMsg = { + submissionId: 1, + judgeResults: [msg], + finished: true } const submission: Submission & { submissionResult: SubmissionResult[] } = { @@ -126,8 +133,12 @@ const db = { findUnique: mockFunc, update: mockFunc }, - $transaction: async (fn: (prisma: typeof db) => Promise) => { - return fn(db) + $executeRaw: mockFunc, + $transaction: async (arg: unknown) => { + if (Array.isArray(arg)) { + return Promise.all(arg) + } + return (arg as (prisma: typeof db) => Promise)(db) } } @@ -174,13 +185,12 @@ describe('SubmissionSubscriptionService', () => { amqpService = module.get(JudgeAMQPService) cache = module.get(CACHE_MANAGER) sandbox.stub(cache, 'get').resolves([]) - sandbox - .stub(db, '$transaction') - .callsFake( - async (fn: (prisma: typeof db) => Promise): Promise => { - return fn(db) - } - ) + sandbox.stub(db, '$transaction').callsFake(async (arg: unknown) => { + if (Array.isArray(arg)) { + return Promise.all(arg) + } + return (arg as (prisma: typeof db) => Promise)(db) + }) }) afterEach(() => { @@ -333,16 +343,24 @@ describe('SubmissionSubscriptionService', () => { it('should resolve', async () => { const spy = sandbox.stub(service, 'updateTestcaseJudgeResult').resolves() - await expect(service.handleJudgerMessage(msg)).not.to.be.rejected + await expect(service.handleJudgerMessage(submissionResponseMsg)).not.to.be + .rejected expect( - spy.calledOnceWithExactly({ - submissionId: msg.submissionId, - problemTestcaseId: msg.judgeResult.testcaseId, - result: Status(msg.resultCode), - cpuTime: BigInt(msg.judgeResult.cpuTime), - memoryUsage: msg.judgeResult.memory, - output: undefined - }) + spy.calledOnceWithExactly([ + { + submissionId: submissionResponseMsg.submissionId, + problemTestcaseId: + submissionResponseMsg.judgeResults[0].judgeResult.testcaseId, + result: Status(submissionResponseMsg.judgeResults[0].resultCode), + cpuTime: BigInt( + submissionResponseMsg.judgeResults[0].judgeResult.cpuTime + ), + memoryUsage: + submissionResponseMsg.judgeResults[0].judgeResult.memory, + output: submissionResponseMsg.judgeResults[0].judgeResult.output, + finished: submissionResponseMsg.judgeResults[0].finished + } + ]) ).to.be.true }) @@ -357,8 +375,12 @@ describe('SubmissionSubscriptionService', () => { error: '', judgeResult } - - await service.handleJudgerMessage(serverErrMsg) + const multiMsg = { + submissionId: 1, + judgeResults: [serverErrMsg, msg], + finished: true + } + await service.handleJudgerMessage(multiMsg) expect(handlerSpy.calledOnceWith(ResultStatus.ServerError, serverErrMsg)) .to.be.true expect(updateSpy.notCalled).to.be.true @@ -369,18 +391,42 @@ describe('SubmissionSubscriptionService', () => { const updateSpy = sandbox .stub(service, 'updateTestcaseJudgeResult') .resolves() - const serverErrMsg = { + const compileErrMsg = { resultCode: 6, submissionId: 1, error: '', judgeResult } + const multiMsg = { + submissionId: 1, + judgeResults: [compileErrMsg, msg], + finished: true + } - await service.handleJudgerMessage(serverErrMsg) - expect(handlerSpy.calledOnceWith(ResultStatus.CompileError, serverErrMsg)) - .to.be.true + await service.handleJudgerMessage(multiMsg) + expect( + handlerSpy.calledOnceWith(ResultStatus.CompileError, compileErrMsg) + ).to.be.true expect(updateSpy.notCalled).to.be.true }) + + it('should throw when judgeResult is missing', async () => { + const missingResultJudgeResponse = { + resultCode: 1, + submissionId: 1, + error: '' + } + + const missingResultMsg = { + submissionId: 1, + judgeResults: [missingResultJudgeResponse], + finished: true + } + + await expect( + service.handleJudgerMessage(missingResultMsg) + ).to.be.rejectedWith(UnprocessableDataException) + }) }) describe('handleJudgeError', () => { @@ -948,37 +994,54 @@ describe('SubmissionSubscriptionService', () => { }) describe('updateTestcaseJudgeResult', () => { - it('should resolves', async () => { - const updateSpy = sandbox.stub(db.submissionResult, 'update').resolves() + it('should return early when submissionResults is empty', async () => { + const transactionSpy = sandbox.stub(db, '$transaction').resolves([]) const updateSubmissionResultSpy = sandbox .stub(service, 'updateSubmissionResult') .resolves() - await service.updateTestcaseJudgeResult(submissionResults[0]) + await service.updateTestcaseJudgeResult([]) + + expect(transactionSpy.notCalled).to.be.true + expect(updateSubmissionResultSpy.notCalled).to.be.true + }) + + it('should run both submission_result and problem_testcase batch updates when there are valid stats targets', async () => { + const executeRawSpy = sandbox.stub(db, '$executeRaw').resolves(1) + const transactionSpy = sandbox.stub(db, '$transaction').resolves([1, 1]) + const updateSubmissionResultSpy = sandbox + .stub(service, 'updateSubmissionResult') + .resolves() + + await service.updateTestcaseJudgeResult(submissionResults) + + expect(transactionSpy.calledOnce).to.be.true + const passedQueries = transactionSpy.firstCall.args[0] + expect(passedQueries).to.have.lengthOf(2) + expect(executeRawSpy.calledTwice).to.be.true - expect( - updateSpy.calledOnceWith({ - where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - submissionId_problemTestcaseId: { - submissionId: submissionResults[0].submissionId, - problemTestcaseId: submissionResults[0].problemTestcaseId - } - }, - data: { - result: submissionResults[0].result, - cpuTime: submissionResults[0].cpuTime, - memoryUsage: submissionResults[0].memoryUsage, - output: null - } - }) - ).to.be.true expect( updateSubmissionResultSpy.calledOnceWith( submissionResults[0].submissionId ) ).to.be.true }) + + it('should skip the problem_testcase batch when all results are Judging/ServerError/Blind/Canceled', async () => { + const executeRawSpy = sandbox.stub(db, '$executeRaw').resolves(1) + const transactionSpy = sandbox.stub(db, '$transaction').resolves([1]) + sandbox.stub(service, 'updateSubmissionResult').resolves() + + const canceledOnly = [ + { ...submissionResults[0], result: ResultStatus.Canceled } + ] + + await service.updateTestcaseJudgeResult(canceledOnly) + + const passedQueries = transactionSpy.firstCall.args[0] + expect(passedQueries).to.have.lengthOf(1) + expect(executeRawSpy.calledOnce).to.be.true + }) }) describe('updateProblemAccepted', () => { From b1e57426416f63d84b31a5941f44ab628e645cfa Mon Sep 17 00:00:00 2001 From: qkrrudals886-boop Date: Mon, 10 Aug 2026 13:40:36 +0000 Subject: [PATCH 12/25] chore: revert gitignore additions unrelated to batch update scope --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 24a25a831e..7a6c59e511 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,3 @@ coverage .idea .codex .claude/settings.local.json -docker-compose.override.yml -phase0-* -.devcontainer/devcontainer-lock.json From 9723891834f7d4f93313cf0a4f4504e0cf52b68f Mon Sep 17 00:00:00 2001 From: lukekeum Date: Fri, 14 Aug 2026 21:29:41 +0000 Subject: [PATCH 13/25] chore(be): remove debugging code --- .../backend/apps/client/src/submission/submission-sub.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 63db9c3997..8221da49fe 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -232,7 +232,6 @@ export class SubmissionSubscriptionService implements OnModuleInit { msg: object ): Promise { const isSubmissionResult = Boolean(msg['finished']) - console.log(isSubmissionResult) if (isSubmissionResult) { const res: SubmissionResponse = plainToInstance(SubmissionResponse, msg) await validateOrReject(res) From e22e457441a4130f2979d2512b885c7007b93646 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Fri, 14 Aug 2026 21:39:40 +0000 Subject: [PATCH 14/25] chore(be): rename validation function --- .../apps/client/src/submission/submission-sub.service.ts | 8 +++++--- .../src/submission/test/submission-sub.service.spec.ts | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/backend/apps/client/src/submission/submission-sub.service.ts b/apps/backend/apps/client/src/submission/submission-sub.service.ts index 8221da49fe..feb73e7496 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -42,7 +42,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { this.amqpService.setMessageHandlers({ onRunMessage: async (msg: object, isUserTest: boolean) => { try { - const res = await this.validateJudgerResponse(msg) + const res = await this.parseResponse(msg) if (res instanceof SubmissionResponse) { return // Ack } @@ -63,7 +63,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { }, onJudgeMessage: async (msg: object) => { try { - const res = await this.validateJudgerResponse(msg) + const res = await this.parseResponse(msg) if (res instanceof JudgerResponse) { // JudgerResponse 메시지는 처리하지 않습니다. @@ -228,16 +228,18 @@ export class SubmissionSubscriptionService implements OnModuleInit { * @throws {ValidationError[]} 유효성 검사 실패 시 발생 */ @Span() - async validateJudgerResponse( + async parseResponse( msg: object ): Promise { const isSubmissionResult = Boolean(msg['finished']) + if (isSubmissionResult) { const res: SubmissionResponse = plainToInstance(SubmissionResponse, msg) await validateOrReject(res) return res } + const res: JudgerResponse = plainToInstance(JudgerResponse, msg) await validateOrReject(res) diff --git a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts index 2ab74baf4a..bf7b3cccdb 100644 --- a/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission-sub.service.spec.ts @@ -216,9 +216,9 @@ describe('SubmissionSubscriptionService', () => { }) }) - describe('validateJudgerResponse', () => { + describe('parseResponse', () => { it('should return JudgerResponse', async () => { - const result = await service.validateJudgerResponse(msg) + const result = await service.parseResponse(msg) expect(result).to.be.deep.equal(msg) }) @@ -231,7 +231,7 @@ describe('SubmissionSubscriptionService', () => { judgeResult } - await expect(service.validateJudgerResponse(invalidMsg)).to.be.rejected + await expect(service.parseResponse(invalidMsg)).to.be.rejected }) }) From b964be6a3e380a4b3d3d744d1c027ace0f97c7cf Mon Sep 17 00:00:00 2001 From: lukekeum Date: Fri, 14 Aug 2026 21:56:37 +0000 Subject: [PATCH 15/25] fix(iris): initialize judgeResponses as empty slice --- apps/iris/src/router/router.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/iris/src/router/router.go b/apps/iris/src/router/router.go index 76b5811c36..3fe807c879 100644 --- a/apps/iris/src/router/router.go +++ b/apps/iris/src/router/router.go @@ -83,7 +83,7 @@ func (r *router[C, E]) Route(path string, id string, data []byte, out chan []byt out <- response.NewJudgeResponse(id, nil, err).Marshal() } - var judgeResult []*response.JudgeResponse + judgeResult := make([]*response.JudgeResponse, 0) for result := range judgeChan { r.errHandle(result.Err) From ffe581a39752d81893cd55b4077e7718a7ec881c Mon Sep 17 00:00:00 2001 From: lukekeum Date: Fri, 14 Aug 2026 22:08:01 +0000 Subject: [PATCH 16/25] feat(be): add finished flag and validation to submission response --- .../apps/client/src/submission/class/judger-response.dto.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts index 5462b9b4ef..60dc7d4f79 100644 --- a/apps/backend/apps/client/src/submission/class/judger-response.dto.ts +++ b/apps/backend/apps/client/src/submission/class/judger-response.dto.ts @@ -48,7 +48,12 @@ export class SubmissionResponse { @IsNotEmpty() submissionId: number + @IsNotEmpty() @ValidateNested({ each: true }) @Type(() => JudgerResponse) judgeResults: JudgerResponse[] + + @IsBoolean() + @IsNotEmpty() + finished: boolean } From 725afbfa88f24f5c1df1917445fbab3c32f86359 Mon Sep 17 00:00:00 2001 From: lukekeum Date: Thu, 20 Aug 2026 23:02:22 +0000 Subject: [PATCH 17/25] Revert "Merge branch 'main' into t2813-batch-submission-result" This reverts commit 0f7a25fc0d68d832918cbe23333abadea41e2092, reversing changes made to 5d312aa8a22e8058c4c3dc5f653dafbcf91c958d. --- .devcontainer/Dockerfile | 2 + .../{ => legacy}/cd-failover-ecs.yml | 0 .gitignore | 12 +- .../apps/client/src/group/group.controller.ts | 20 +- .../apps/client/src/group/group.service.ts | 166 +- .../migration.sql | 13 + apps/backend/prisma/schema.prisma | 3 + apps/backend/prisma/seed.ts | 16 + apps/backend/schema.gql | 11 + .../_components/FinishedNoticePanel.tsx | 2 +- .../(main)/_components/ServiceCards.tsx | 50 +- .../course/[courseId]/(overview)/layout.tsx | 4 +- apps/iris/.env.example | 11 + apps/iris/Dockerfile | 5 +- .../request/example_check_request.json | 13 + .../request/example_generate_request.json | 15 + .../request/example_validate_request.json | 13 + .../response/example_check_response.json | 34 + .../response/example_generate_response.json | 9 + .../response/example_validate_response.json | 19 + apps/iris/example/tools/example_checker.cc | 18 + apps/iris/example/tools/example_generator.cc | 11 + apps/iris/example/tools/example_validator.cc | 16 + apps/iris/go.mod | 5 +- apps/iris/go.sum | 2 + apps/iris/lib/testlib.h | 6252 +++++++++++++++++ apps/iris/main.go | 32 +- apps/iris/src/common/constants/constants.go | 4 +- .../result/{chResult.go => ch_result.go} | 0 apps/iris/src/common/taskerror/error.go | 108 + apps/iris/src/common/taskerror/error_test.go | 33 + apps/iris/src/common/taskresult/result.go | 9 + apps/iris/src/connector/rabbitmq/connector.go | 52 +- .../src/connector/rabbitmq/connector_test.go | 31 + apps/iris/src/handler/check/models.go | 51 + apps/iris/src/handler/errors.go | 56 - apps/iris/src/handler/extract.go | 11 + apps/iris/src/handler/generate/factory.go | 65 + .../handler/generate/generate_handler_test.go | 200 + apps/iris/src/handler/generate/models.go | 75 + apps/iris/src/handler/generate/task.go | 313 + apps/iris/src/handler/interface.go | 49 - apps/iris/src/handler/judge-handler.go | 401 -- apps/iris/src/handler/judge/factory.go | 68 + .../src/handler/judge/judge_handler_test.go | 99 + apps/iris/src/handler/judge/models.go | 64 + apps/iris/src/handler/judge/task.go | 184 + apps/iris/src/handler/parse_error.go | 7 + apps/iris/src/handler/result.go | 9 + apps/iris/src/handler/resultCode.go | 35 - apps/iris/src/handler/result_code.go | 44 + apps/iris/src/handler/result_code_test.go | 15 + apps/iris/src/handler/run/factory.go | 77 + apps/iris/src/handler/run/models.go | 64 + .../run_handler_test.go} | 18 +- apps/iris/src/handler/run/task.go | 194 + apps/iris/src/handler/runner.go | 145 + apps/iris/src/handler/task.go | 19 + apps/iris/src/handler/task_error.go | 7 + apps/iris/src/handler/tool_limits.go | 80 + apps/iris/src/handler/tool_limits_test.go | 100 + apps/iris/src/handler/validate/factory.go | 58 + apps/iris/src/handler/validate/models.go | 44 + apps/iris/src/handler/validate/task.go | 214 + apps/iris/src/handler/validate/task_test.go | 55 + .../handler/validate/validate_handler_test.go | 71 + apps/iris/src/loader/element.go | 11 +- apps/iris/src/loader/postgres.go | 126 +- apps/iris/src/loader/s3.go | 21 +- apps/iris/src/router/response/check.go | 26 + apps/iris/src/router/response/generate.go | 26 + apps/iris/src/router/response/judge.go | 54 +- apps/iris/src/router/response/response.go | 9 + apps/iris/src/router/response/sender.go | 137 + apps/iris/src/router/response/sender_test.go | 52 + apps/iris/src/router/response/validate.go | 26 + apps/iris/src/router/router.go | 140 +- apps/iris/src/service/build/error.go | 19 + apps/iris/src/service/build/unit.go | 131 + apps/iris/src/service/build/unit_test.go | 100 + .../judger/{langConfig.go => lang_config.go} | 5 +- .../judger/{resultCode.go => result_code.go} | 0 .../iris/src/service/sandbox/judger/runner.go | 1 + .../sandbox/{langConfig.go => lang_config.go} | 2 +- apps/iris/src/service/sandbox/runner.go | 1 + .../sandbox/{statusCode.go => status_code.go} | 0 apps/iris/src/service/testcase/data.go | 2 +- apps/iris/src/service/testcase/manager.go | 73 +- .../tests/data/submission/1_generate.json | 9 + .../Succeed (Problem with Assignment).bru | 89 + .../client/Course/Create Question/Succeed.bru | 88 +- .../[403] Not a Course Member.bru | 4 +- .../[404] Nonexistent Problem.bru | 10 +- .../Create Question/[422] Problem Only.bru | 35 + .../Course/Get Question Detail/Succeed.bru | 46 +- .../Course/Get Questions List/Succeed.bru | 44 +- .../decommission-stage/.terraform.lock.hcl | 25 + infra/aws/decommission-stage/README.md | 50 + infra/aws/decommission-stage/cloudfront.tf | 68 + infra/aws/decommission-stage/compute.tf | 29 + .../decommission-stage/failover-network.tf | 229 + infra/aws/decommission-stage/iam.tf | 85 + infra/aws/decommission-stage/imports.tf | 116 + .../aws/decommission-stage/legacy-network.tf | 33 + .../aws/decommission-stage/load-balancing.tf | 93 + infra/aws/decommission-stage/main.tf | 27 + infra/aws/decommission-stage/message-queue.tf | 60 + infra/aws/decommission-stage/observability.tf | 26 + infra/aws/decommission-stage/removed.tf | 9 + infra/aws/dns/acm-validation.tf | 17 + infra/aws/dns/main.tf | 14 + infra/aws/k8s-iam/main.tf | 2 +- infra/aws/storage/main.tf | 2 + infra/aws/storage/redis.tf | 30 - infra/aws/storage/removed.tf | 9 + infra/aws/storage/s3_plag.tf | 28 + infra/aws/vpc/.terraform.lock.hcl | 1 + infra/aws/vpc/main.tf | 2 + infra/aws/vpc/moved.tf | 36 + infra/aws/vpc/network-instance.tf | 55 - infra/aws/vpc/outputs.tf | 41 +- infra/aws/vpc/private-network.tf | 105 +- infra/aws/vpc/public-network.tf | 30 +- infra/aws/vpc/removed.tf | 105 + infra/aws/vpc/security-group.tf | 212 +- infra/legacy/README.md | 5 + infra/legacy/production/codedang/disabled.tf | 3 + infra/legacy/production/network/disabled.tf | 3 + infra/legacy/production/storage/disabled.tf | 3 + infra/legacy/rc/acm-validation/disabled.tf | 3 + infra/legacy/rc/codedang/disabled.tf | 3 + infra/legacy/rc/network/disabled.tf | 3 + infra/legacy/rc/storage/disabled.tf | 3 + .../rc/terraform-configuration/disabled.tf | 3 + .../stage/skku-network-proxy/disabled.tf | 3 + .../terraform-configuration/disabled.tf | 3 + package.json | 4 + pnpm-lock.yaml | 113 + scripts/e2e-polygon-test.ts | 646 ++ scripts/e2e-polygon.sh | 146 + scripts/rmq_metrics.py | 284 + 141 files changed, 12560 insertions(+), 1377 deletions(-) rename .github/workflows/{ => legacy}/cd-failover-ecs.yml (100%) create mode 100644 apps/backend/prisma/migrations/20260812054938_add_assignment_id_composite_fk_to_course_qna/migration.sql create mode 100644 apps/iris/example/request/example_check_request.json create mode 100644 apps/iris/example/request/example_generate_request.json create mode 100644 apps/iris/example/request/example_validate_request.json create mode 100644 apps/iris/example/response/example_check_response.json create mode 100644 apps/iris/example/response/example_generate_response.json create mode 100644 apps/iris/example/response/example_validate_response.json create mode 100644 apps/iris/example/tools/example_checker.cc create mode 100644 apps/iris/example/tools/example_generator.cc create mode 100644 apps/iris/example/tools/example_validator.cc create mode 100644 apps/iris/lib/testlib.h rename apps/iris/src/common/result/{chResult.go => ch_result.go} (100%) create mode 100644 apps/iris/src/common/taskerror/error.go create mode 100644 apps/iris/src/common/taskerror/error_test.go create mode 100644 apps/iris/src/common/taskresult/result.go create mode 100644 apps/iris/src/connector/rabbitmq/connector_test.go create mode 100644 apps/iris/src/handler/check/models.go delete mode 100644 apps/iris/src/handler/errors.go create mode 100644 apps/iris/src/handler/extract.go create mode 100644 apps/iris/src/handler/generate/factory.go create mode 100644 apps/iris/src/handler/generate/generate_handler_test.go create mode 100644 apps/iris/src/handler/generate/models.go create mode 100644 apps/iris/src/handler/generate/task.go delete mode 100644 apps/iris/src/handler/interface.go delete mode 100644 apps/iris/src/handler/judge-handler.go create mode 100644 apps/iris/src/handler/judge/factory.go create mode 100644 apps/iris/src/handler/judge/judge_handler_test.go create mode 100644 apps/iris/src/handler/judge/models.go create mode 100644 apps/iris/src/handler/judge/task.go create mode 100644 apps/iris/src/handler/parse_error.go create mode 100644 apps/iris/src/handler/result.go delete mode 100644 apps/iris/src/handler/resultCode.go create mode 100644 apps/iris/src/handler/result_code.go create mode 100644 apps/iris/src/handler/result_code_test.go create mode 100644 apps/iris/src/handler/run/factory.go create mode 100644 apps/iris/src/handler/run/models.go rename apps/iris/src/handler/{judge-handler_test.go => run/run_handler_test.go} (89%) create mode 100644 apps/iris/src/handler/run/task.go create mode 100644 apps/iris/src/handler/runner.go create mode 100644 apps/iris/src/handler/task.go create mode 100644 apps/iris/src/handler/task_error.go create mode 100644 apps/iris/src/handler/tool_limits.go create mode 100644 apps/iris/src/handler/tool_limits_test.go create mode 100644 apps/iris/src/handler/validate/factory.go create mode 100644 apps/iris/src/handler/validate/models.go create mode 100644 apps/iris/src/handler/validate/task.go create mode 100644 apps/iris/src/handler/validate/task_test.go create mode 100644 apps/iris/src/handler/validate/validate_handler_test.go create mode 100644 apps/iris/src/router/response/check.go create mode 100644 apps/iris/src/router/response/generate.go create mode 100644 apps/iris/src/router/response/sender.go create mode 100644 apps/iris/src/router/response/sender_test.go create mode 100644 apps/iris/src/router/response/validate.go create mode 100644 apps/iris/src/service/build/error.go create mode 100644 apps/iris/src/service/build/unit.go create mode 100644 apps/iris/src/service/build/unit_test.go rename apps/iris/src/service/sandbox/judger/{langConfig.go => lang_config.go} (98%) rename apps/iris/src/service/sandbox/judger/{resultCode.go => result_code.go} (100%) rename apps/iris/src/service/sandbox/{langConfig.go => lang_config.go} (92%) rename apps/iris/src/service/sandbox/{statusCode.go => status_code.go} (100%) create mode 100644 apps/iris/tests/data/submission/1_generate.json create mode 100644 collection/client/Course/Create Question/Succeed (Problem with Assignment).bru create mode 100644 collection/client/Course/Create Question/[422] Problem Only.bru create mode 100644 infra/aws/decommission-stage/.terraform.lock.hcl create mode 100644 infra/aws/decommission-stage/README.md create mode 100644 infra/aws/decommission-stage/cloudfront.tf create mode 100644 infra/aws/decommission-stage/compute.tf create mode 100644 infra/aws/decommission-stage/failover-network.tf create mode 100644 infra/aws/decommission-stage/iam.tf create mode 100644 infra/aws/decommission-stage/imports.tf create mode 100644 infra/aws/decommission-stage/legacy-network.tf create mode 100644 infra/aws/decommission-stage/load-balancing.tf create mode 100644 infra/aws/decommission-stage/main.tf create mode 100644 infra/aws/decommission-stage/message-queue.tf create mode 100644 infra/aws/decommission-stage/observability.tf create mode 100644 infra/aws/decommission-stage/removed.tf create mode 100644 infra/aws/dns/acm-validation.tf delete mode 100644 infra/aws/storage/redis.tf create mode 100644 infra/aws/storage/removed.tf create mode 100644 infra/aws/storage/s3_plag.tf create mode 100644 infra/aws/vpc/moved.tf delete mode 100644 infra/aws/vpc/network-instance.tf create mode 100644 infra/aws/vpc/removed.tf create mode 100644 infra/legacy/production/codedang/disabled.tf create mode 100644 infra/legacy/production/network/disabled.tf create mode 100644 infra/legacy/production/storage/disabled.tf create mode 100644 infra/legacy/rc/acm-validation/disabled.tf create mode 100644 infra/legacy/rc/codedang/disabled.tf create mode 100644 infra/legacy/rc/network/disabled.tf create mode 100644 infra/legacy/rc/storage/disabled.tf create mode 100644 infra/legacy/rc/terraform-configuration/disabled.tf create mode 100644 infra/legacy/stage/skku-network-proxy/disabled.tf create mode 100644 infra/legacy/stage/skku-network-proxy/terraform-configuration/disabled.tf create mode 100644 scripts/e2e-polygon-test.ts create mode 100755 scripts/e2e-polygon.sh create mode 100644 scripts/rmq_metrics.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index be0e108b66..7cc419b3e0 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,6 +7,8 @@ RUN chmod -R 777 /app/sandbox RUN curl -L "https://github.com/skkuding/Judger/releases/download/v1.0.0-alpha.4/libjudger-$(dpkg --print-architecture).so" -o "/app/sandbox/libjudger.so" RUN chmod 750 /app/sandbox/libjudger.so +COPY ./apps/iris/lib/testlib.h /usr/include/testlib.h + # Install dependencies RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends pypy3 \ diff --git a/.github/workflows/cd-failover-ecs.yml b/.github/workflows/legacy/cd-failover-ecs.yml similarity index 100% rename from .github/workflows/cd-failover-ecs.yml rename to .github/workflows/legacy/cd-failover-ecs.yml diff --git a/.gitignore b/.gitignore index 7a6c59e511..ec06d261a4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,3 @@ -### Load test results ### -tests/load/results/* -!tests/load/results/.gitkeep - ### Node ### # Logs logs @@ -150,3 +146,11 @@ coverage .idea .codex .claude/settings.local.json + +# agent +.agents/* +.codex +.claude/* +.claude/settings.local.json +**/CLAUDE** +CLAUDE.* diff --git a/apps/backend/apps/client/src/group/group.controller.ts b/apps/backend/apps/client/src/group/group.controller.ts index fdfe4975a6..cd8e367c98 100644 --- a/apps/backend/apps/client/src/group/group.controller.ts +++ b/apps/backend/apps/client/src/group/group.controller.ts @@ -298,25 +298,29 @@ export class CourseController { } /** - * Create a new Q&A for the course. - * @param req - Authenticated request object containing user info. - * @param courseId - The ID of the course (Group ID). - * @param createCourseQnADto - DTO containing title, content, and private setting. - * @param problemId - (Optional) The ID of the related problem. - * @returns The created Course Q&A. + * 강좌 내 새로운 Q&A 질문 게시글을 생성합니다. + * + * @param {AuthenticatedRequest} req - 사용자 정보를 포함한 인증된 요청 객체 + * @param {number} courseId - 강좌 ID (Group ID) + * @param {CreateCourseQnADto} createCourseQnADto - 게시글 생성 데이터 (제목, 내용, 비공개 여부) + * @param {number} problemId - (선택) 연관된 문제 ID (`assignmentId`와 함께 전달 필수) + * @param {number} assignmentId - (선택) 연관된 과제 ID (`problemId`와 함께 전달 필수) + * @returns {number} 생성된 Q&A 게시글 정보 (연관 과제 정보 포함) */ @Post(':id/qna') async createCourseQnA( @Req() req: AuthenticatedRequest, @Param('id', GroupIDPipe) courseId: number, @Body() createCourseQnADto: CreateCourseQnADto, - @Query('problemId', OptionalParseIntPipe) problemId?: number + @Query('problemId', OptionalParseIntPipe) problemId?: number, + @Query('assignmentId', OptionalParseIntPipe) assignmentId?: number ) { return await this.courseService.createCourseQnA( req.user.id, courseId, createCourseQnADto, - problemId + problemId, + assignmentId ) } diff --git a/apps/backend/apps/client/src/group/group.service.ts b/apps/backend/apps/client/src/group/group.service.ts index 5479e8c2f2..e1f69e240b 100644 --- a/apps/backend/apps/client/src/group/group.service.ts +++ b/apps/backend/apps/client/src/group/group.service.ts @@ -1495,24 +1495,33 @@ export class CourseService { /** * 강좌 내 Q&A 게시글을 생성합니다. * - * @param userId - 작성자 ID - * @param courseId - 강좌 ID - * @param data - 게시글 제목, 내용 등 생성 데이터 - * @param problemId - (선택) 질문과 연관된 문제 ID - * @returns 생성된 Q&A 정보 (연관된 과제 정보 포함) + * @param {number} userId 작성자 아이디 + * @param {number} courseId 강좌 아이디 + * @param {CreateCourseQnADto} data 게시글 생성 데이터 + * @param {number} [problemId] 질문과 연관된 문제 아이디 + * @param {number} [assignmentId] 연관된 과제 아이디 + * @returns 생성된 Q&A 게시글 정보(과제 및 문제 연관 정보 포함)를 반환, 연관 과제가 없을 경우 해당 필드들은 null 반환 + * + * @throws {UnprocessableDataException} `problemId`와 `assignmentId` 중 하나만 전달된 경우 + * @throws {EntityNotExistException} 해당 강좌(Course)가 존재하지 않거나 과제-문제 연관 관계가 올바르지 않은 경우 + * @throws {ForbiddenAccessException} 질문 작성자가 해당 강좌의 수강생/멤버가 아닌 경우 * - * @remarks - * - `isExercise` 필드를 포함하여 과제 유형을 반환합니다. - * - 문제가 여러 과제에 포함된 경우, 가장 최근 과제(assignmentId desc) 정보를 기준으로 매핑합니다. */ async createCourseQnA( userId: number, courseId: number, data: CreateCourseQnADto, - problemId?: number + problemId?: number, + assignmentId?: number ) { const groupId = courseId + if ((problemId == null) !== (assignmentId == null)) { + throw new UnprocessableDataException( + 'problemId and assignmentId must be provided together' + ) + } + const group = await this.prisma.group.findUnique({ where: { id: groupId, courseInfo: { isNot: null } } }) @@ -1527,12 +1536,21 @@ export class CourseService { throw new ForbiddenAccessException('Not a member of this course') } - if (problemId) { - const problem = await this.prisma.problem.findUnique({ - where: { id: problemId } + if (problemId && assignmentId) { + const assignmentProblem = await this.prisma.assignmentProblem.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + assignmentId_problemId: { assignmentId, problemId } + }, + select: { + assignment: { select: { groupId: true } } + } }) - if (!problem) { - throw new EntityNotExistException('Problem') + if ( + !assignmentProblem || + assignmentProblem.assignment.groupId !== groupId + ) { + throw new EntityNotExistException('AssignmentProblem') } } @@ -1551,10 +1569,11 @@ export class CourseService { group: { connect: { id: group.id } }, order: newOrder, readBy: [userId], - ...(problemId + ...(problemId && assignmentId ? { category: QnACategory.Problem, - problem: { connect: { id: problemId } } + problem: { connect: { id: problemId } }, + assignment: { connect: { id: assignmentId } } } : { category: QnACategory.General @@ -1572,40 +1591,25 @@ export class CourseService { createTime: true, readBy: true, createdBy: { select: { username: true } }, - problem: { + assignment: { select: { - assignmentProblem: { - where: { - assignment: { - groupId: group.id - } - }, - orderBy: { assignmentId: 'desc' }, - take: 1, - select: { - assignment: { - select: { - id: true, - title: true, - isExercise: true - } - } - } - } + id: true, + title: true, + isExercise: true } } } }) - const assignment = newQnA.problem?.assignmentProblem?.[0]?.assignment + const assignment = newQnA.assignment // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { problem, ...rest } = newQnA + const { assignment: _, ...rest } = newQnA return { ...rest, - assignmentId: assignment?.id, - assignmentTitle: assignment?.title, - isExercise: assignment?.isExercise + assignmentId: assignment?.id ?? null, + assignmentTitle: assignment?.title ?? null, + isExercise: assignment?.isExercise ?? null } }) } @@ -1613,12 +1617,12 @@ export class CourseService { /** * 강좌의 Q&A 목록을 조회합니다. * - * @param userId - 조회하는 사용자 ID (읽음 여부 확인용) - * @param courseId - 강좌 ID - * @param filter - 검색어, 카테고리, 답변 여부 등 필터 옵션 - * @param cursor - 페이지네이션 커서 (마지막으로 로드된 QnA ID) - * @param take - 한 번에 가져올 개수 - * @returns Q&A 목록 (연관된 과제 정보 및 읽음 여부 포함) + * @param {number | null} userId - 조회하는 사용자 ID (읽음 여부 확인용) + * @param {number} courseId - 강좌 ID + * @param {GetCourseQnAsFilterDto} filter - 검색어, 카테고리, 답변 여부 등 필터 옵션 + * @param {number | null} cursor - 페이지네이션 커서 (마지막으로 로드된 QnA ID) + * @param {number} take - 한 번에 가져올 개수 + * @returns Q&A 목록 (연관된 과제 정보 및 읽음 여부 포함, 연관 과제가 없을 경우 해당 필드들은 null 반환) * */ async getCourseQnAs( @@ -1703,26 +1707,11 @@ export class CourseService { createdBy: { select: { username: true } }, // eslint-disable-next-line @typescript-eslint/naming-convention _count: { select: { comments: true } }, - problem: { + assignment: { select: { - assignmentProblem: { - where: { - assignment: { - groupId - } - }, - orderBy: { assignmentId: 'desc' }, - take: 1, - select: { - assignment: { - select: { - id: true, - title: true, - isExercise: true - } - } - } - } + id: true, + title: true, + isExercise: true } } }, @@ -1732,14 +1721,13 @@ export class CourseService { } }) - return qnas.map(({ readBy, problem, ...rest }) => { - const assignment = problem?.assignmentProblem?.[0]?.assignment + return qnas.map(({ readBy, assignment, ...rest }) => { return { ...rest, isRead: userId == null || readBy.includes(userId), - assignmentId: assignment?.id, - assignmentTitle: assignment?.title, - isExercise: assignment?.isExercise + assignmentId: assignment?.id ?? null, + assignmentTitle: assignment?.title ?? null, + isExercise: assignment?.isExercise ?? null } }) } @@ -1747,10 +1735,10 @@ export class CourseService { /** * 특정 Q&A 게시글의 상세 정보를 조회합니다. * - * @param userId - 조회하는 사용자 ID - * @param courseId - 강좌 ID - * @param order - 게시글 순서 번호 - * @returns Q&A 상세 정보 (댓글, 연관 과제 정보 포함) + * @param {number | null} userId - 조회하는 사용자 ID + * @param {number} courseId - 강좌 ID + * @param {number} order - 게시글 순서 번호 + * @returns Q&A 상세 정보 (댓글, 연관 과제 정보 포함, 연관 과제가 없을 경우 해당 필드들은 null 반환) * * @throws EntityNotExistException - 게시글이 존재하지 않는 경우 * @throws ForbiddenAccessException - 비공개 게시글에 대한 접근 권한이 없는 경우 @@ -1793,24 +1781,11 @@ export class CourseService { include: { createdBy: { select: { username: true } } }, orderBy: { order: 'asc' } }, - problem: { + assignment: { select: { - assignmentProblem: { - where: { - assignment: { groupId } - }, - orderBy: { assignmentId: 'desc' }, - take: 1, - select: { - assignment: { - select: { - id: true, - title: true, - isExercise: true - } - } - } - } + id: true, + title: true, + isExercise: true } } } @@ -1843,15 +1818,14 @@ export class CourseService { }) } - const assignment = qna.problem?.assignmentProblem?.[0]?.assignment // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { problem, readBy, ...rest } = qna + const { assignment, readBy, ...rest } = qna return { ...rest, - assignmentId: assignment?.id, - assignmentTitle: assignment?.title, - isExercise: assignment?.isExercise + assignmentId: assignment?.id ?? null, + assignmentTitle: assignment?.title ?? null, + isExercise: assignment?.isExercise ?? null } } diff --git a/apps/backend/prisma/migrations/20260812054938_add_assignment_id_composite_fk_to_course_qna/migration.sql b/apps/backend/prisma/migrations/20260812054938_add_assignment_id_composite_fk_to_course_qna/migration.sql new file mode 100644 index 0000000000..0154a7c2d5 --- /dev/null +++ b/apps/backend/prisma/migrations/20260812054938_add_assignment_id_composite_fk_to_course_qna/migration.sql @@ -0,0 +1,13 @@ +-- Persist the assignment selected when a course Q&A is created. +-- The column is nullable so existing Q&As retain their legacy lookup behavior. +ALTER TABLE "public"."course_qna" +ADD COLUMN "assignment_id" INTEGER; + +-- Keep Q&As when an assignment is removed; only clear the stored mapping. +ALTER TABLE "public"."course_qna" +ADD CONSTRAINT "course_qna_assignment_id_fkey" +FOREIGN KEY ("assignment_id") REFERENCES "public"."assignment"("id") +ON DELETE SET NULL ON UPDATE CASCADE NOT VALID; + +ALTER TABLE "public"."course_qna" +VALIDATE CONSTRAINT "course_qna_assignment_id_fkey"; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index d85e95cd28..c1f617541e 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -448,6 +448,7 @@ model Assignment { submission Submission[] testSubmission TestSubmission[] CheckRequest CheckRequest[] + courseQnA CourseQnA[] @@index([groupId, week]) @@map("assignment") @@ -1000,6 +1001,7 @@ model CourseQnA { createdById Int? @map("created_by_id") groupId Int @map("group_id") problemId Int? @map("problem_id") + assignmentId Int? @map("assignment_id") title String content String category QnACategory @default(General) @@ -1011,6 +1013,7 @@ model CourseQnA { createdBy User? @relation("CreatedByUserCourseQnA", fields: [createdById], references: [id], onDelete: SetNull) group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) problem Problem? @relation("ProblemToCourseQnA", fields: [problemId], references: [id], onDelete: Cascade) + assignment Assignment? @relation(fields: [assignmentId], references: [id], onDelete: SetNull) comments CourseQnAComment[] @@unique([groupId, order]) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 617c692545..8881be02b7 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -5513,6 +5513,22 @@ const createCourseQnA = async () => { } }) + // QnA 3 (Problem, Public, Direct Assignment Mapping) + await prisma.courseQnA.create({ + data: { + groupId: privateGroup1.id, + createdById: users[0].id, + assignmentId: ongoingAssignments[0].id, + problemId: problems[1].id, + category: QnACategory.Problem, + title: '2번 문제 과제 연결 질문입니다.', + content: '2번 문제의 과제 정보를 함께 확인하고 싶습니다.', + isResolved: false, + isPrivate: false, + order: 3 + } + }) + // Comment 1 (Staff reply to QnA 1) await prisma.courseQnAComment.create({ data: { diff --git a/apps/backend/schema.gql b/apps/backend/schema.gql index a51119897e..a60a15b85a 100644 --- a/apps/backend/schema.gql +++ b/apps/backend/schema.gql @@ -71,6 +71,7 @@ type Assignment { assignmentProblem: [AssignmentProblem!] assignmentRecord: [AssignmentRecord!] autoFinalizeScore: Boolean! + courseQnA: [CourseQnA!] createTime: DateTime! createdBy: User createdById: Int @@ -105,6 +106,7 @@ type AssignmentCount { CheckRequest: Int! assignmentProblem: Int! assignmentRecord: Int! + courseQnA: Int! submission: Int! testSubmission: Int! } @@ -462,6 +464,7 @@ type AssignmentWithParticipants { assignmentProblem: [AssignmentProblem!] assignmentRecord: [AssignmentRecord!] autoFinalizeScore: Boolean! + courseQnA: [CourseQnA!] createTime: DateTime! createdBy: User createdById: Int @@ -492,6 +495,7 @@ type AssignmentWithScores { assignmentProblem: [AssignmentProblem!] assignmentRecord: [AssignmentRecord!] autoFinalizeScore: Boolean! + courseQnA: [CourseQnA!] createTime: DateTime! createdBy: User createdById: Int @@ -1748,6 +1752,8 @@ type CourseNoticeSumAggregate { type CourseQnA { _count: CourseQnACount! + assignment: Assignment + assignmentId: Int category: QnACategory! comments: [CourseQnAComment!] content: String! @@ -1767,6 +1773,7 @@ type CourseQnA { } type CourseQnAAvgAggregate { + assignmentId: Float createdById: Float groupId: Float id: Float @@ -1838,6 +1845,7 @@ type CourseQnACount { type CourseQnACountAggregate { _all: Int! + assignmentId: Int! category: Int! content: Int! createTime: Int! @@ -1853,6 +1861,7 @@ type CourseQnACountAggregate { } type CourseQnAMaxAggregate { + assignmentId: Int category: QnACategory content: String createTime: DateTime @@ -1867,6 +1876,7 @@ type CourseQnAMaxAggregate { } type CourseQnAMinAggregate { + assignmentId: Int category: QnACategory content: String createTime: DateTime @@ -1881,6 +1891,7 @@ type CourseQnAMinAggregate { } type CourseQnASumAggregate { + assignmentId: Int createdById: Int groupId: Int id: Int diff --git a/apps/frontend/app/(client)/(code-editor)/_components/FinishedNoticePanel.tsx b/apps/frontend/app/(client)/(code-editor)/_components/FinishedNoticePanel.tsx index 1acd1e25a5..de87cc9846 100644 --- a/apps/frontend/app/(client)/(code-editor)/_components/FinishedNoticePanel.tsx +++ b/apps/frontend/app/(client)/(code-editor)/_components/FinishedNoticePanel.tsx @@ -60,7 +60,7 @@ function VisitProblemButton({ problemId }: VisitProblemButtonProps) { + ))} + + + ) } -const features = SERVICE_TABS.flatMap((tab) => FEATURE_LIST[tab]) +// const features = SERVICE_TABS.flatMap((tab) => FEATURE_LIST[tab]) export function ServiceCards() { - // const [selectedTab, setSelectedTab] = useState('CONTEST') + const [selectedTab, setSelectedTab] = useState('NOTICE') // const features = FEATURE_LIST[selectedTab] + const features = SERVICE_TABS.flatMap((tab) => FEATURE_LIST[tab]) return (
@@ -92,6 +121,7 @@ export function ServiceCards() { */} +
diff --git a/apps/frontend/app/admin/course/[courseId]/(overview)/layout.tsx b/apps/frontend/app/admin/course/[courseId]/(overview)/layout.tsx index 0b58560cb7..de4b3f0beb 100644 --- a/apps/frontend/app/admin/course/[courseId]/(overview)/layout.tsx +++ b/apps/frontend/app/admin/course/[courseId]/(overview)/layout.tsx @@ -76,7 +76,7 @@ export default function CourseDetailLayout({
-