diff --git a/apps/backend/apps/admin/src/contest/contest.service.spec.ts b/apps/backend/apps/admin/src/contest/contest.service.spec.ts index 3f5f367656..e490ce4df9 100644 --- a/apps/backend/apps/admin/src/contest/contest.service.spec.ts +++ b/apps/backend/apps/admin/src/contest/contest.service.spec.ts @@ -39,6 +39,8 @@ const contest: Contest = { groupId, title: 'title', description: 'description', + penalty: 20, + lastPenalty: false, startTime, endTime, isVisible: true, @@ -63,6 +65,8 @@ const contestWithCount = { groupId, title: 'title', description: 'description', + penalty: 20, + lastPenalty: false, startTime, endTime, isVisible: true, @@ -90,6 +94,8 @@ const contestWithParticipants: ContestWithParticipants = { groupId, title: 'title', description: 'description', + penalty: 20, + lastPenalty: false, startTime, endTime, isVisible: true, @@ -151,6 +157,7 @@ const problem: Problem = { } const contestProblem: ContestProblem = { + id: 1, order: 0, contestId, problemId, diff --git a/apps/backend/apps/admin/src/problem/mock/mock.ts b/apps/backend/apps/admin/src/problem/mock/mock.ts index ee3e9637be..37afefc398 100644 --- a/apps/backend/apps/admin/src/problem/mock/mock.ts +++ b/apps/backend/apps/admin/src/problem/mock/mock.ts @@ -371,6 +371,8 @@ export const exampleContest: Contest = { id: 1, title: 'example', description: 'example', + penalty: 20, + lastPenalty: false, groupId: 1, createdById: 1, isVisible: true, @@ -391,6 +393,7 @@ export const exampleContest: Contest = { } export const exampleContestProblems: ContestProblem[] = [ { + id: 1, order: 1, contestId: 1, problemId: 1, @@ -399,6 +402,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 2, order: 2, contestId: 1, problemId: 2, @@ -407,6 +411,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 3, order: 3, contestId: 1, problemId: 3, @@ -415,6 +420,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 4, order: 4, contestId: 1, problemId: 4, @@ -423,6 +429,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 5, order: 5, contestId: 1, problemId: 5, @@ -431,6 +438,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 6, order: 6, contestId: 1, problemId: 6, @@ -439,6 +447,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 7, order: 7, contestId: 1, problemId: 7, @@ -447,6 +456,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 8, order: 8, contestId: 1, problemId: 8, @@ -455,6 +465,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 9, order: 9, contestId: 1, problemId: 9, @@ -463,6 +474,7 @@ export const exampleContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 10, order: 10, contestId: 1, problemId: 10, @@ -474,6 +486,7 @@ export const exampleContestProblems: ContestProblem[] = [ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ { + id: 1, order: 1, contestId: 1, problemId: 2, @@ -482,6 +495,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 2, order: 2, contestId: 1, problemId: 3, @@ -490,6 +504,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 3, order: 3, contestId: 1, problemId: 4, @@ -498,6 +513,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 4, order: 4, contestId: 1, problemId: 5, @@ -506,6 +522,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 5, order: 5, contestId: 1, problemId: 6, @@ -514,6 +531,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 6, order: 6, contestId: 1, problemId: 7, @@ -522,6 +540,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 7, order: 7, contestId: 1, problemId: 8, @@ -530,6 +549,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 8, order: 8, contestId: 1, problemId: 9, @@ -538,6 +558,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 9, order: 9, contestId: 1, problemId: 10, @@ -546,6 +567,7 @@ export const exampleOrderUpdatedContestProblems: ContestProblem[] = [ updateTime: new Date() }, { + id: 10, order: 10, contestId: 1, problemId: 1, diff --git a/apps/backend/apps/client/src/contest/contest.controller.ts b/apps/backend/apps/client/src/contest/contest.controller.ts index 5b51561c88..2b5e3b0e41 100644 --- a/apps/backend/apps/client/src/contest/contest.controller.ts +++ b/apps/backend/apps/client/src/contest/contest.controller.ts @@ -129,4 +129,15 @@ export class ContestController { groupId ) } + + @Get(':id/leaderboard') + async getLeaderboard( + @Req() req: AuthenticatedRequest, + @Param('id', IDValidationPipe) contestId: number + ) { + return await this.contestService.getContestLeaderboard( + req.user.id, + contestId + ) + } } diff --git a/apps/backend/apps/client/src/contest/contest.service.spec.ts b/apps/backend/apps/client/src/contest/contest.service.spec.ts index 14d745874f..030db22e45 100644 --- a/apps/backend/apps/client/src/contest/contest.service.spec.ts +++ b/apps/backend/apps/client/src/contest/contest.service.spec.ts @@ -33,6 +33,8 @@ const contest = { groupId, title: 'title', description: 'description', + penalty: 100, + lastPenalty: false, startTime: now.add(-1, 'day').toDate(), endTime: now.add(1, 'day').toDate(), isVisible: true, @@ -512,4 +514,14 @@ describe('ContestService', () => { ).to.be.rejectedWith(ForbiddenAccessException) }) }) + + describe('getContestLeaderboard', () => { + it('should return leaderboard of the contest', async () => { + const leaderboard = await service.getContestLeaderboard( + user01Id, + contestId + ) + expect(leaderboard).to.be.ok + }) + }) }) diff --git a/apps/backend/apps/client/src/contest/contest.service.ts b/apps/backend/apps/client/src/contest/contest.service.ts index 3bc4b25faf..95b6a07b08 100644 --- a/apps/backend/apps/client/src/contest/contest.service.ts +++ b/apps/backend/apps/client/src/contest/contest.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common' -import { Prisma, type Contest } from '@prisma/client' +import { Prisma, Role, type Contest } from '@prisma/client' import { OPEN_SPACE_ID } from '@libs/constants' import { ConflictFoundException, @@ -541,4 +541,139 @@ export class ContestService { where: { contestId_userId: { contestId, userId } } }) } + + async getContestLeaderboard(userId: number, contestId: number) { + const isRegistered = + (await this.prisma.contestRecord.findFirst({ + where: { + userId, + contestId + } + })) != null + + const user = await this.prisma.user.findUnique({ + where: { + id: userId + }, + select: { + id: true, + role: true + } + }) + + if ( + !isRegistered && + user?.role !== Role.Admin && + user?.role !== Role.SuperAdmin + ) { + throw new ForbiddenAccessException('Not registered in this contest') + } + + const sum = await this.prisma.contestProblem.aggregate({ + where: { + contestId + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + _sum: { + score: true + } + }) + const maxScore = sum._sum?.score ?? 0 + + const contestRecords = await this.prisma.contestRecord.findMany({ + where: { + contestId + }, + select: { + userId: true, + user: { + select: { + username: true + } + }, + score: true, + totalPenalty: true, + contestProblemRecord: { + select: { + score: true, + timePenalty: true, + submitCountPenalty: true, + contestProblem: { + select: { + order: true, + problem: { + select: { + id: true + } + } + } + } + } + } + }, + orderBy: [ + { + score: 'desc' + }, + { + totalPenalty: 'asc' + }, + { + lastAcceptedTime: 'asc' + } + ] + }) + + // 문제별 제출 횟수 데이터 가져오기 + const submissionCounts = await this.prisma.submission.groupBy({ + by: ['userId', 'problemId'], // userId와 problemId로 그룹화 + // eslint-disable-next-line @typescript-eslint/naming-convention + _count: { + id: true // 제출 횟수를 세기 위해 id를 카운트 + }, + where: { + contestId + } + }) + + // 유저별 문제별 제출 횟수를 매핑 + const submissionCountMap: { + [userId: number]: { [problemId: number]: number } + } = {} + submissionCounts.forEach((submission) => { + const { userId, problemId, _count } = submission + if (!userId || !problemId || !_count) return + if (!submissionCountMap[userId]) { + submissionCountMap[userId] = {} + } + submissionCountMap[userId][problemId] = _count.id // 문제별 제출 횟수 저장 + }) + + let rank = 1 + const leaderboard = contestRecords.map((contestRecord) => { + const { contestProblemRecord, userId, ...rest } = contestRecord + const problemRecords = contestProblemRecord.map((record) => { + const { contestProblem, submitCountPenalty, timePenalty, ...rest } = + record + return { + ...rest, + order: contestProblem.order, + problemId: contestProblem.problem.id, + penalty: submitCountPenalty + timePenalty, + submissionCount: + submissionCountMap[userId!]?.[contestProblem.problem.id] ?? 0 // 기본값 0 설정 + } + }) + return { + ...rest, + problemRecords, + rank: rank++ + } + }) + + return { + maxScore, + leaderboard + } + } } diff --git a/apps/backend/apps/client/src/problem/mock/problem.mock.ts b/apps/backend/apps/client/src/problem/mock/problem.mock.ts index 461d919781..bdab8eb07f 100644 --- a/apps/backend/apps/client/src/problem/mock/problem.mock.ts +++ b/apps/backend/apps/client/src/problem/mock/problem.mock.ts @@ -66,6 +66,7 @@ export const problems: Problem[] = [ export const contestProblems = [ { + id: 1, order: 1, contestId: 1, problemId: 1, @@ -77,6 +78,7 @@ export const contestProblems = [ } }, { + id: 2, order: 2, contestId: 1, problemId: 2, @@ -91,6 +93,7 @@ export const contestProblems = [ export const contestProblemsWithScore = [ { + id: 1, order: 1, contestId: 1, problemId: 1, @@ -104,6 +107,7 @@ export const contestProblemsWithScore = [ submissionTime: null }, { + id: 2, order: 2, contestId: 1, problemId: 2, diff --git a/apps/backend/apps/client/src/submission/mock/contest.mock.ts b/apps/backend/apps/client/src/submission/mock/contest.mock.ts new file mode 100644 index 0000000000..11cf0e39f9 --- /dev/null +++ b/apps/backend/apps/client/src/submission/mock/contest.mock.ts @@ -0,0 +1,19 @@ +import type { Contest } from '@prisma/client' + +export const normalContest: Pick< + Contest, + 'startTime' | 'penalty' | 'lastPenalty' +> = { + startTime: new Date(), + penalty: 1, + lastPenalty: false +} + +export const lastPenaltyContest: Pick< + Contest, + 'startTime' | 'penalty' | 'lastPenalty' +> = { + startTime: new Date(), + penalty: 1, + lastPenalty: true +} diff --git a/apps/backend/apps/client/src/submission/mock/contestProblem.mock.ts b/apps/backend/apps/client/src/submission/mock/contestProblem.mock.ts new file mode 100644 index 0000000000..6c6a5fbef3 --- /dev/null +++ b/apps/backend/apps/client/src/submission/mock/contestProblem.mock.ts @@ -0,0 +1,4 @@ +export const contestProblem = { + id: 1, + score: 100 +} diff --git a/apps/backend/apps/client/src/submission/mock/contestRecord.mock.ts b/apps/backend/apps/client/src/submission/mock/contestRecord.mock.ts index 29e70a800a..d06c2ae665 100644 --- a/apps/backend/apps/client/src/submission/mock/contestRecord.mock.ts +++ b/apps/backend/apps/client/src/submission/mock/contestRecord.mock.ts @@ -1,7 +1,8 @@ -export const contestRecord = { - id: 1, - acceptedProblemNum: 2, - score: 10, - totalPenalty: 1, - finishTime: new Date() -} +import type { ContestRecord } from '@prisma/client' + +export const contestRecordsMock: Partial[] = [ + { + id: 1, + score: 12 + } +] 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 bd8b64566f..9a64fc4c94 100644 --- a/apps/backend/apps/client/src/submission/submission-sub.service.ts +++ b/apps/backend/apps/client/src/submission/submission-sub.service.ts @@ -274,6 +274,7 @@ export class SubmissionSubscriptionService implements OnModuleInit { problemId: true, userId: true, contestId: true, + createTime: true, updateTime: true, submissionResult: { select: { @@ -302,27 +303,83 @@ export class SubmissionSubscriptionService implements OnModuleInit { }) if (submission.userId && submission.contestId) - await this.calculateSubmissionScore(submission, allAccepted) + await this.updateContestRecord(submission, allAccepted) await this.updateProblemScore(submission.id) await this.updateProblemAccepted(submission.problemId, allAccepted) } @Span() - async calculateSubmissionScore( + async updateContestRecord( submission: Pick< Submission, - 'problemId' | 'contestId' | 'userId' | 'updateTime' + 'problemId' | 'contestId' | 'userId' | 'createTime' | 'updateTime' >, isAccepted: boolean ): Promise { - const contestId = submission.contestId! - const userId = submission.userId! + const { contestId, problemId, userId, createTime, updateTime } = submission - let toBeAddedScore = 0, - toBeAddedPenalty = 0, - toBeAddedAcceptedProblemNum = 0, - isFinishTimeToBeUpdated = false + if (!contestId || !userId) + throw new UnprocessableDataException( + `contestId: ${contestId}, userId: ${userId} is empty` + ) + + const _submissions = await this.prisma.submission.findMany({ + where: { + contestId, + problemId, + result: ResultStatus.Accepted + }, + select: { + id: true, + userId: true, + createTime: true + } + }) + + const isNewAccept = + _submissions.filter((submission) => submission.userId === userId) + .length === 1 + + // 재채점시 고려하여 만들었음. + const isFirstSolver = + _submissions.filter((submssion) => submssion.createTime < createTime) + .length === 0 + + if (!isNewAccept || !isAccepted) return + + const _contest = await this.prisma.contest.findUniqueOrThrow({ + where: { + id: contestId ?? -1 + }, + select: { + startTime: true, + penalty: true, + lastPenalty: true, + submission: { + where: { + userId + }, + select: { + id: true + } + } + } + }) + + const contestProblem = await this.prisma.contestProblem.findUniqueOrThrow({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_problemId: { + contestId, + problemId + } + }, + select: { + id: true, + score: true + } + }) const contestRecord = await this.prisma.contestRecord.findUniqueOrThrow({ where: { @@ -333,44 +390,85 @@ export class SubmissionSubscriptionService implements OnModuleInit { } }, select: { - id: true, - acceptedProblemNum: true, - score: true, - totalPenalty: true, - finishTime: true + id: true } }) - if (isAccepted) { - toBeAddedScore = ( - await this.prisma.contestProblem.findFirstOrThrow({ - where: { - contestId, - problemId: submission.problemId - }, - select: { - score: true - } - }) - ).score - isFinishTimeToBeUpdated = true - toBeAddedAcceptedProblemNum = 1 - } else { - toBeAddedPenalty = 1 - } + const { submission: submissions, ...contest } = _contest + const { penalty, lastPenalty } = contest + const { id: contestProblemId, score } = contestProblem + const contestRecordId = contestRecord.id + const submitCount = submissions.length + const submitCountPenalty = Math.floor(penalty * (submitCount - 1)) + + const submitTime = new Date(updateTime).getTime() + const startTime = new Date(contest.startTime).getTime() + const timePenalty = Math.floor((submitTime - startTime) / 60000) + + // 1. contest problem record의 score와 penalty들을 upsert(update or create) 한다. + await this.prisma.contestProblemRecord.upsert({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestProblemId_contestRecordId: { + contestProblemId, + contestRecordId + } + }, + update: { + score, + submitCountPenalty, + timePenalty, + isFirstSolver + }, + create: { + contestProblemId, + contestRecordId, + score, + submitCountPenalty, + isFirstSolver + } + }) + + const contestProblemRecords = + await this.prisma.contestProblemRecord.findMany({ + where: { + contestProblemId, + contestRecordId + }, + select: { + score: true, + timePenalty: true, + submitCountPenalty: true + } + }) + + const [scoreSum, submitCountPenaltySum, timePenaltySum, maxTimePenalty] = + contestProblemRecords.reduce( + (acc, record) => { + acc[0] += record.score + acc[1] += record.submitCountPenalty + acc[2] += record.timePenalty + acc[3] = Math.max(acc[3], record.timePenalty) + return acc + }, + [0, 0, 0, 0] + ) + // 2. contest record의 score, penalty, lastAcceptedTime를 update 한다. await this.prisma.contestRecord.update({ where: { - id: contestRecord.id + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_userId: { + contestId, + userId + } }, data: { - acceptedProblemNum: - contestRecord.acceptedProblemNum + toBeAddedAcceptedProblemNum, - score: contestRecord.score + toBeAddedScore, - totalPenalty: contestRecord.totalPenalty + toBeAddedPenalty, - finishTime: isFinishTimeToBeUpdated - ? submission.updateTime - : contestRecord.finishTime + score: scoreSum, + totalPenalty: lastPenalty + ? maxTimePenalty + submitCountPenaltySum + : timePenaltySum + submitCountPenaltySum, + lastAcceptedTime: updateTime } }) } 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 cbd5774c8d..bf9af53c10 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 @@ -21,7 +21,9 @@ import { import { UnprocessableDataException } from '@libs/exception' import { PrismaService } from '@libs/prisma' import { problems } from '@admin/problem/mock/mock' -import { contestRecord } from '../mock/contestRecord.mock' +import { normalContest } from '../mock/contest.mock' +import { contestProblem } from '../mock/contestProblem.mock' +import { contestRecordsMock } from '../mock/contestRecord.mock' import { submissions } from '../mock/submission.mock' import { submissionResults } from '../mock/submissionResult.mock' import { SubmissionSubscriptionService } from '../submission-sub.service' @@ -64,19 +66,27 @@ const db = { submission: { findUnique: mockFunc, update: mockFunc, - findFirst: mockFunc + findFirst: mockFunc, + findMany: mockFunc }, submissionResult: { findFirstOrThrow: mockFunc, updateMany: mockFunc, update: mockFunc }, + contest: { + findUniqueOrThrow: mockFunc + }, contestRecord: { findUniqueOrThrow: mockFunc, update: mockFunc }, contestProblem: { - findFirstOrThrow: mockFunc + findUniqueOrThrow: mockFunc + }, + contestProblemRecord: { + upsert: mockFunc, + findMany: mockFunc }, problem: { update: mockFunc, @@ -340,7 +350,6 @@ describe('SubmissionSubscriptionService', () => { const updateManySpy = sandbox .stub(db.submissionResult, 'updateMany') .resolves() - expect(updateSpy.notCalled).to.be.true expect(updateManySpy.notCalled).to.be.true }) @@ -353,7 +362,7 @@ describe('SubmissionSubscriptionService', () => { .resolves(submission) const updateSpy = sandbox.stub(db.submission, 'update').resolves() const submissionScoreSpy = sandbox - .stub(service, 'calculateSubmissionScore') + .stub(service, 'updateContestRecord') .resolves() const problemScoreSpy = sandbox .stub(service, 'updateProblemScore') @@ -382,6 +391,7 @@ describe('SubmissionSubscriptionService', () => { userId: true, contestId: true, updateTime: true, + createTime: true, submissionResult: { select: { result: true @@ -408,9 +418,7 @@ describe('SubmissionSubscriptionService', () => { it('should return when judge not finished', async () => { sandbox.stub(db.submission, 'findUnique').resolves(undefined) const updateSpy = sandbox.stub(db.submission, 'update').resolves() - const scoreSpy = sandbox - .stub(service, 'calculateSubmissionScore') - .resolves() + const scoreSpy = sandbox.stub(service, 'updateContestRecord').resolves() const acceptSpy = sandbox .stub(service, 'updateProblemAccepted') .resolves() @@ -429,10 +437,7 @@ describe('SubmissionSubscriptionService', () => { const findSpy = sandbox .stub(db.submission, 'findUnique') .resolves(contestSubmission) - const submissionScoreSpy = sandbox.stub( - service, - 'calculateSubmissionScore' - ) + const submissionScoreSpy = sandbox.stub(service, 'updateContestRecord') const problemScoreSpy = sandbox.stub(service, 'updateProblemScore') await service.updateSubmissionResult(1) @@ -446,20 +451,88 @@ describe('SubmissionSubscriptionService', () => { }) }) - describe('calculateSubmissionScore', () => { - it('should resolves', async () => { - const findUniqueSpy = sandbox + describe('updateContestRecord', () => { + it('should update records when new accepted submission', async () => { + const submissionFindManySpy = sandbox + .stub(db.submission, 'findMany') + .resolves([contestSubmission]) + const contestFindUniqueSpy = sandbox + .stub(db.contest, 'findUniqueOrThrow') + .resolves({ + normalContest, + submission: submissions + }) + const contestProblemFindUniqueSpy = sandbox + .stub(db.contestProblem, 'findUniqueOrThrow') + .resolves(contestProblem) + const contestRecordFindUniqueSpy = sandbox .stub(db.contestRecord, 'findUniqueOrThrow') - .resolves(contestRecord) - const findFirstSpy = sandbox - .stub(db.contestProblem, 'findFirstOrThrow') - .resolves({ score: 100 }) - const updateSpy = sandbox.stub(db.contestRecord, 'update').resolves() + .resolves(contestRecordsMock[0]) + const problemRecordFindManySpy = sandbox + .stub(db.contestProblemRecord, 'findMany') + .resolves([]) + const upsertProblemRecordSpy = sandbox + .stub(db.contestProblemRecord, 'upsert') + .resolves() + const updateRecordSpy = sandbox + .stub(db.contestRecord, 'update') + .resolves() - await service.calculateSubmissionScore(contestSubmission, true) + // when + await service.updateContestRecord(contestSubmission, true) + // then + expect( + submissionFindManySpy.calledOnceWith({ + where: { + contestId: contestSubmission.contestId, + problemId: contestSubmission.problemId, + result: ResultStatus.Accepted + }, + select: { + id: true, + userId: true, + createTime: true + } + }) + ).to.be.true + expect( + contestFindUniqueSpy.calledOnceWith({ + where: { + id: contestSubmission.contestId + }, + select: { + startTime: true, + penalty: true, + lastPenalty: true, + submission: { + where: { + userId: contestSubmission.userId + }, + select: { + id: true + } + } + } + }) + ).to.be.true + expect( + contestProblemFindUniqueSpy.calledOnceWith({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + contestId_problemId: { + contestId: contestSubmission.contestId, + problemId: contestSubmission.problemId + } + }, + select: { + id: true, + score: true + } + }) + ).to.be.true expect( - findUniqueSpy.calledOnceWith({ + contestRecordFindUniqueSpy.calledOnceWith({ where: { // eslint-disable-next-line @typescript-eslint/naming-convention contestId_userId: { @@ -468,59 +541,58 @@ describe('SubmissionSubscriptionService', () => { } }, select: { - id: true, - acceptedProblemNum: true, - score: true, - totalPenalty: true, - finishTime: true + id: true } }) ).to.be.true + expect(upsertProblemRecordSpy.calledOnce).to.be.true + expect( - findFirstSpy.calledOnceWith({ + problemRecordFindManySpy.calledOnceWith({ where: { - contestId: contestSubmission.contestId, - problemId: contestSubmission.problemId + contestProblemId: contestProblem.id, + contestRecordId: contestRecordsMock[0].id }, select: { - score: true + score: true, + timePenalty: true, + submitCountPenalty: true } }) ).to.be.true - expect(updateSpy.calledOnce).to.be.true + expect(updateRecordSpy.calledOnce).to.be.true }) - it('should resolves', async () => { - const findUniqueSpy = sandbox - .stub(db.contestRecord, 'findUniqueOrThrow') - .resolves(contestRecord) - const findFirstSpy = sandbox - .stub(db.contestProblem, 'findFirstOrThrow') - .resolves({ score: 100 }) - const updateSpy = sandbox.stub(db.contestRecord, 'update').resolves() + it('should reject when submission is not accepted', async () => { + const submissionFindManySpy = sandbox + .stub(db.submission, 'findMany') + .resolves([]) + const upsertProblemRecordSpy = sandbox + .stub(db.contestProblemRecord, 'upsert') + .resolves() + const updateRecordSpy = sandbox + .stub(db.contestRecord, 'update') + .resolves() - await service.calculateSubmissionScore(contestSubmission, false) + // when + await service.updateContestRecord(contestSubmission, false) expect( - findUniqueSpy.calledOnceWith({ + submissionFindManySpy.calledOnceWith({ where: { - // eslint-disable-next-line @typescript-eslint/naming-convention - contestId_userId: { - contestId: contestSubmission.contestId, - userId: contestSubmission.userId - } + contestId: contestSubmission.contestId, + problemId: contestSubmission.problemId, + result: ResultStatus.Accepted }, select: { id: true, - acceptedProblemNum: true, - score: true, - totalPenalty: true, - finishTime: true + userId: true, + createTime: true } }) ).to.be.true - expect(findFirstSpy.notCalled).to.be.true - expect(updateSpy.calledOnce).to.be.true + expect(upsertProblemRecordSpy.notCalled).to.be.true + expect(updateRecordSpy.notCalled).to.be.true }) }) diff --git a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts index 12dfbcbb6c..08be4c1eb0 100644 --- a/apps/backend/apps/client/src/submission/test/submission.service.spec.ts +++ b/apps/backend/apps/client/src/submission/test/submission.service.spec.ts @@ -73,6 +73,8 @@ const mockContest: Contest = { groupId: 1, title: 'SKKU Coding Platform 모의대회', description: 'test', + penalty: 20, + lastPenalty: false, invitationCode: 'test', startTime: new Date(), endTime: new Date(), diff --git a/apps/backend/prisma/migrations/20250123114853_contest_leaderboard/migration.sql b/apps/backend/prisma/migrations/20250123114853_contest_leaderboard/migration.sql new file mode 100644 index 0000000000..943337973d --- /dev/null +++ b/apps/backend/prisma/migrations/20250123114853_contest_leaderboard/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - The primary key for the `contest_problem` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[contest_id,problem_id]` on the table `contest_problem` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "contest" ADD COLUMN "last_penalty" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "penalty" INTEGER NOT NULL DEFAULT 0; + +-- AlterTable +ALTER TABLE "contest_problem" DROP CONSTRAINT "contest_problem_pkey", +ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "contest_problem_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "contest_problem_record" ( + "contest_problem_id" INTEGER NOT NULL, + "user_id" INTEGER NOT NULL, + "score" INTEGER NOT NULL DEFAULT 0, + "finish_time" TIMESTAMP(3), + "submitCountPenalty" INTEGER NOT NULL DEFAULT 0, + "timePenalty" INTEGER NOT NULL DEFAULT 0, + "create_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "update_time" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contest_problem_record_pkey" PRIMARY KEY ("contest_problem_id","user_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "contest_problem_contest_id_problem_id_key" ON "contest_problem"("contest_id", "problem_id"); + +-- AddForeignKey +ALTER TABLE "contest_problem_record" ADD CONSTRAINT "contest_problem_record_contest_problem_id_fkey" FOREIGN KEY ("contest_problem_id") REFERENCES "contest_problem"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contest_problem_record" ADD CONSTRAINT "contest_problem_record_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20250125081617_edit_contest_problem_record_table/migration.sql b/apps/backend/prisma/migrations/20250125081617_edit_contest_problem_record_table/migration.sql new file mode 100644 index 0000000000..9803aab3d5 --- /dev/null +++ b/apps/backend/prisma/migrations/20250125081617_edit_contest_problem_record_table/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - The primary key for the `contest_problem_record` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `user_id` on the `contest_problem_record` table. All the data in the column will be lost. + - Added the required column `contest_record_id` to the `contest_problem_record` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "contest_problem_record" DROP CONSTRAINT "contest_problem_record_user_id_fkey"; + +-- AlterTable +ALTER TABLE "contest_problem_record" DROP CONSTRAINT "contest_problem_record_pkey", +DROP COLUMN "user_id", +ADD COLUMN "contest_record_id" INTEGER NOT NULL, +ADD CONSTRAINT "contest_problem_record_pkey" PRIMARY KEY ("contest_problem_id", "contest_record_id"); + +-- AddForeignKey +ALTER TABLE "contest_problem_record" ADD CONSTRAINT "contest_problem_record_contest_record_id_fkey" FOREIGN KEY ("contest_record_id") REFERENCES "contest_record"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20250127143044_add_firstsolver_column_in_contestproblemrecord/migration.sql b/apps/backend/prisma/migrations/20250127143044_add_firstsolver_column_in_contestproblemrecord/migration.sql new file mode 100644 index 0000000000..a1f14eb372 --- /dev/null +++ b/apps/backend/prisma/migrations/20250127143044_add_firstsolver_column_in_contestproblemrecord/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "contest_problem_record" ADD COLUMN "is_first_solver" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/migrations/migration_lock.toml b/apps/backend/prisma/migrations/migration_lock.toml index fbffa92c2b..648c57fd59 100644 --- a/apps/backend/prisma/migrations/migration_lock.toml +++ b/apps/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) +# It should be added in your version-control system (e.g., Git) provider = "postgresql" \ No newline at end of file diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 830572cf51..a9e3e742a0 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -54,13 +54,13 @@ model User { problem Problem[] assignment Assignment[] assignmentRecord AssignmentRecord[] + contest Contest[] + contestRecord ContestRecord[] workbook Workbook[] submission Submission[] useroauth UserOAuth? CodeDraft CodeDraft[] Image Image[] - contest Contest[] - contestRecord ContestRecord[] @@map("user") } @@ -331,6 +331,9 @@ model Contest { groupId Int @map("group_id") title String description String + // 대회의 페널티 (0 ≤ penalty ≤ 100), + penalty Int @default(0) + lastPenalty Boolean @default(false) @map("last_penalty") posterUrl String? participationTarget String? competitionMethod String? @@ -356,36 +359,60 @@ model Contest { } model ContestProblem { - order Int - contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int @map("contest_id") - problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) - problemId Int @map("problem_id") + id Int @id @default(autoincrement()) + order Int + contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int @map("contest_id") + problem Problem @relation(fields: [problemId], references: [id], onDelete: Cascade) + problemId Int @map("problem_id") // 각 문제의 점수 (비율 아님) - score Int @default(0) - createTime DateTime @default(now()) @map("create_time") - updateTime DateTime @updatedAt @map("update_time") + score Int @default(0) + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + contestProblemRecord ContestProblemRecord[] - @@id([contestId, problemId]) - // @@unique([contestId, problemId]) + // @@id([contestId, problemId]) + @@unique([contestId, problemId]) @@map("contest_problem") } -model ContestRecord { - id Int @id @default(autoincrement()) - contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) - contestId Int @map("contest_id") - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) - userId Int? @map("user_id") - acceptedProblemNum Int @default(0) @map("accepted_problem_num") +model ContestProblemRecord { + contestProblemId Int @map("contest_problem_id") + contestRecordId Int @map("contest_record_id") score Int @default(0) - // finishTime: Pariticipant가 가장 최근에 AC를 받은 시각 + // finishTime: Pariticipant가 가장 최근에 좋은 제출을 받은 시각 finishTime DateTime? @map("finish_time") - // totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter - totalPenalty Int @default(0) @map("total_penalty") + // (좋은 제출 전까지 유저 u가 문제 p에 제출한 횟수) × (대회의 페널티) + submitCountPenalty Int @default(0) + // 대회 시작부터 좋은 제출이 제출되기까지 쇼요된 시간, 단위는 분 + timePenalty Int @default(0) + isFirstSolver Boolean @default(false) @map("is_first_solver") createTime DateTime @default(now()) @map("create_time") updateTime DateTime @updatedAt @map("update_time") + contestProblem ContestProblem @relation(fields: [contestProblemId], references: [id], onDelete: Cascade) + contestRecord ContestRecord @relation(fields: [contestRecordId], references: [id], onDelete: Cascade) + + @@id([contestProblemId, contestRecordId]) + @@map("contest_problem_record") +} + +model ContestRecord { + id Int @id @default(autoincrement()) + contest Contest @relation(fields: [contestId], references: [id], onDelete: Cascade) + contestId Int @map("contest_id") + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + userId Int? @map("user_id") + acceptedProblemNum Int @default(0) @map("accepted_problem_num") + score Int @default(0) + // finishTime: Pariticipant가 가장 최근에 AC를 받은 시각 + lastAcceptedTime DateTime? @map("finish_time") + // totalPenalty: Submission 시, AC를 받지 못했을 때, 올라가는 Counter + totalPenalty Int @default(0) @map("total_penalty") + createTime DateTime @default(now()) @map("create_time") + updateTime DateTime @updatedAt @map("update_time") + contestProblemRecord ContestProblemRecord[] + @@unique([contestId, userId]) @@map("contest_record") } diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index e6c357f6a4..09b1219c02 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -16,7 +16,8 @@ import { type CodeDraft, type AssignmentRecord, type Contest, - type ContestRecord + type ContestRecord, + type ContestProblemRecord } from '@prisma/client' import { hash } from 'argon2' import { readFile } from 'fs/promises' @@ -50,6 +51,7 @@ const privateWorkbooks: Workbook[] = [] const submissions: Submission[] = [] const assignmentAnnouncements: Announcement[] = [] const contestAnnouncements: Announcement[] = [] +const contestProblemRecords: ContestProblemRecord[] = [] const createUsers = async () => { // create super admin user @@ -1791,6 +1793,191 @@ const createWorkbooks = async () => { } const createSubmissions = async () => { + submissions.push( + await prisma.submission.create({ + data: { + userId: users[0].id, + problemId: problems[0].id, + contestId: ongoingContests[0].id, + code: [ + { + id: 1, + locked: false, + text: `#include +int main(void) { + printf("Hello, World!\n"); + return 0; +}` + } + ], + language: Language.C, + result: ResultStatus.Judging + } + }) + ) + + await prisma.submissionResult.create({ + data: { + submissionId: submissions[submissions.length - 1].id, + problemTestcaseId: problemTestcases[0].id, + result: ResultStatus.Accepted, + cpuTime: 12345, + memoryUsage: 12345 + } + }) + await prisma.submission.update({ + where: { + id: submissions[submissions.length - 1].id + }, + data: { result: ResultStatus.Accepted } + }) + + submissions.push( + await prisma.submission.create({ + data: { + userId: users[1].id, + problemId: problems[1].id, + contestId: ongoingContests[0].id, + code: [ + { + id: 1, + locked: false, + text: `#include +int main(void) { + std::cout << "Hello, World!" << endl; + return 0; +}` + } + ], + language: Language.Cpp, + result: ResultStatus.Judging + } + }) + ) + await prisma.submissionResult.create({ + data: { + submissionId: submissions[submissions.length - 1].id, + problemTestcaseId: problemTestcases[1].id, + result: ResultStatus.WrongAnswer, + cpuTime: 12345, + memoryUsage: 12345 + } + }) + await prisma.submission.update({ + where: { + id: submissions[submissions.length - 1].id + }, + data: { result: ResultStatus.WrongAnswer } + }) + + submissions.push( + await prisma.submission.create({ + data: { + userId: users[2].id, + problemId: problems[2].id, + contestId: ongoingContests[0].id, + code: [ + { + id: 1, + locked: false, + text: `class Main { + public static void main(String[] args) { + System.out.println("Hello, World!"); + } +}` + } + ], + language: Language.Java, + result: ResultStatus.Judging + } + }) + ) + await prisma.submissionResult.create({ + data: { + submissionId: submissions[submissions.length - 1].id, + problemTestcaseId: problemTestcases[2].id, + result: ResultStatus.CompileError + } + }) + await prisma.submission.update({ + where: { + id: submissions[submissions.length - 1].id + }, + data: { result: ResultStatus.CompileError } + }) + + submissions.push( + await prisma.submission.create({ + data: { + userId: users[3].id, + problemId: problems[3].id, + contestId: ongoingContests[0].id, + code: [ + { + id: 1, + locked: false, + text: `print("Hello, World!")` + } + ], + language: Language.Python3, + result: ResultStatus.Judging + } + }) + ) + await prisma.submissionResult.create({ + data: { + submissionId: submissions[submissions.length - 1].id, + problemTestcaseId: problemTestcases[3].id, + result: ResultStatus.RuntimeError, + cpuTime: 12345, + memoryUsage: 12345 + } + }) + await prisma.submission.update({ + where: { + id: submissions[submissions.length - 1].id + }, + data: { result: ResultStatus.RuntimeError } + }) + + submissions.push( + await prisma.submission.create({ + data: { + userId: users[4].id, + problemId: problems[4].id, + contestId: ongoingContests[0].id, + code: [ + { + id: 1, + locked: false, + text: `#include +int main(void) { + printf("Hello, World!\n"); + return 0; +}` + } + ], + language: Language.C, + result: ResultStatus.Judging + } + }) + ) + await prisma.submissionResult.create({ + data: { + submissionId: submissions[submissions.length - 1].id, + problemTestcaseId: problemTestcases[4].id, + result: ResultStatus.TimeLimitExceeded, + cpuTime: 12345, + memoryUsage: 12345 + } + }) + await prisma.submission.update({ + where: { + id: submissions[submissions.length - 1].id + }, + data: { result: ResultStatus.TimeLimitExceeded } + }) + submissions.push( await prisma.submission.create({ data: { @@ -1816,7 +2003,7 @@ int main(void) { await prisma.submissionResult.create({ data: { - submissionId: submissions[0].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[0].id, result: ResultStatus.Accepted, cpuTime: 12345, @@ -1825,7 +2012,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.Accepted } }) @@ -1854,7 +2041,7 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[1].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[1].id, result: ResultStatus.WrongAnswer, cpuTime: 12345, @@ -1863,7 +2050,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.WrongAnswer } }) @@ -1892,14 +2079,14 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[2].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[2].id, result: ResultStatus.CompileError } }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.CompileError } }) @@ -1924,7 +2111,7 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[3].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[3].id, result: ResultStatus.RuntimeError, cpuTime: 12345, @@ -1933,7 +2120,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.RuntimeError } }) @@ -1962,7 +2149,7 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[4].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[4].id, result: ResultStatus.TimeLimitExceeded, cpuTime: 12345, @@ -1971,7 +2158,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.TimeLimitExceeded } }) @@ -2000,7 +2187,7 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[5].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[5].id, result: ResultStatus.MemoryLimitExceeded, cpuTime: 12345, @@ -2009,7 +2196,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.MemoryLimitExceeded } }) @@ -2034,7 +2221,7 @@ int main(void) { ) await prisma.submissionResult.create({ data: { - submissionId: submissions[6].id, + submissionId: submissions[submissions.length - 1].id, problemTestcaseId: problemTestcases[6].id, result: ResultStatus.OutputLimitExceeded, cpuTime: 12345, @@ -2043,7 +2230,7 @@ int main(void) { }) await prisma.submission.update({ where: { - id: submissions[0].id + id: submissions[submissions.length - 1].id }, data: { result: ResultStatus.OutputLimitExceeded } }) @@ -2247,6 +2434,22 @@ const createContestRecords = async () => { return contestRecords } +const createContestProblemRecords = async () => { + // contest 1 problems for + for (let i = 0; i < 5; ++i) { + contestProblemRecords.push( + await prisma.contestProblemRecord.create({ + data: { + contestProblemId: i + 1, + contestRecordId: 1 + } + }) + ) + } + + return contestProblemRecords +} + const main = async () => { await createUsers() await createGroups() @@ -2254,12 +2457,13 @@ const main = async () => { await createProblems() await createAssignments() await createContests() + await createContestRecords() await createWorkbooks() await createSubmissions() await createAnnouncements() await createCodeDrafts() await createAssignmentRecords() - await createContestRecords() + await createContestProblemRecords() } main() diff --git a/collection/client/Contest/Get Leaderboard/succeed.bru b/collection/client/Contest/Get Leaderboard/succeed.bru new file mode 100644 index 0000000000..4881481acc --- /dev/null +++ b/collection/client/Contest/Get Leaderboard/succeed.bru @@ -0,0 +1,11 @@ +meta { + name: succeed + type: http + seq: 1 +} + +get { + url: :id/leaderboard + body: none + auth: none +}