feat(phase10B): Solved.ac PS 모듈 신규 + bojHandle 등록
- prisma: PsProblem / PsBookmark / PsSyncState 모델, StudyLog.psProblemId - migration: phase10_ps_tables - ps/solved-ac.client: axios 클라이언트(rate-limit 3req/s, 지수 백오프 3회) searchProblems / getProblem / getUser / getUserProblemStats / getUserTop100 - ps/ps.module + service + controller: search/bookmark/sync 6 endpoint - JWT guard, throttler, sync 멱등성 (PsSyncState lastSolvedCount) - sync 시 시스템 Subject "PS" 자동 생성, StudyLog bulk insert (중복 skip) - me/auth: bojHandle / focusUnits PATCH 지원, MeUser 응답 확장 - vitest: solved-ac.client 7 테스트 통과 (4 메서드 + 429 재시도) Phase 10B DoD 통과.
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^5.0.0",
|
||||
"axios": "^1.7",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
|
||||
81
backend/pnpm-lock.yaml
generated
81
backend/pnpm-lock.yaml
generated
@@ -32,6 +32,9 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^5.0.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
axios:
|
||||
specifier: ^1.7
|
||||
version: 1.15.0
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@@ -980,6 +983,12 @@ packages:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
axios@1.15.0:
|
||||
resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -1125,6 +1134,10 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
@@ -1219,6 +1232,10 @@ packages:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
depd@2.0.0:
|
||||
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1299,6 +1316,10 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.27.7:
|
||||
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1477,6 +1498,15 @@ packages:
|
||||
flatted@3.4.2:
|
||||
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1488,6 +1518,10 @@ packages:
|
||||
typescript: '>3.6.0'
|
||||
webpack: ^5.11.0
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1575,6 +1609,10 @@ packages:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.2:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2072,6 +2110,10 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3509,6 +3551,16 @@ snapshots:
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.15.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
@@ -3669,6 +3721,10 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
commander@4.1.1: {}
|
||||
@@ -3750,6 +3806,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
|
||||
destroy@1.2.0: {}
|
||||
@@ -3813,6 +3871,13 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esbuild@0.27.7:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.7
|
||||
@@ -4069,6 +4134,8 @@ snapshots:
|
||||
|
||||
flatted@3.4.2: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
@@ -4091,6 +4158,14 @@ snapshots:
|
||||
typescript: 5.7.2
|
||||
webpack: 5.97.1
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fresh@0.5.2: {}
|
||||
@@ -4185,6 +4260,10 @@ snapshots:
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
hasown@2.0.2:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
@@ -4642,6 +4721,8 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qs@6.14.2:
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `study_logs` ADD COLUMN `psProblemId` INTEGER NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `ps_problems` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`bojId` INTEGER NOT NULL,
|
||||
`title` VARCHAR(191) NOT NULL,
|
||||
`titleKo` VARCHAR(191) NULL,
|
||||
`level` INTEGER NOT NULL,
|
||||
`tags` JSON NULL,
|
||||
`acceptedUserCount` INTEGER NULL,
|
||||
`averageTries` DOUBLE NULL,
|
||||
`solvedacUpdatedAt` DATETIME(3) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `ps_problems_bojId_key`(`bojId`),
|
||||
INDEX `ps_problems_level_idx`(`level`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `ps_bookmarks` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`psProblemId` INTEGER NOT NULL,
|
||||
`memo` VARCHAR(191) NULL,
|
||||
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `ps_bookmarks_userId_idx`(`userId`),
|
||||
UNIQUE INDEX `ps_bookmarks_userId_psProblemId_key`(`userId`, `psProblemId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `ps_sync_states` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`userId` INTEGER NOT NULL,
|
||||
`lastSyncedAt` DATETIME(3) NULL,
|
||||
`lastSolvedCount` INTEGER NOT NULL DEFAULT 0,
|
||||
`lastTier` INTEGER NULL,
|
||||
`updatedAt` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `ps_sync_states_userId_key`(`userId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `study_logs_psProblemId_idx` ON `study_logs`(`psProblemId`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `study_logs` ADD CONSTRAINT `study_logs_psProblemId_fkey` FOREIGN KEY (`psProblemId`) REFERENCES `ps_problems`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ps_bookmarks` ADD CONSTRAINT `ps_bookmarks_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ps_bookmarks` ADD CONSTRAINT `ps_bookmarks_psProblemId_fkey` FOREIGN KEY (`psProblemId`) REFERENCES `ps_problems`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `ps_sync_states` ADD CONSTRAINT `ps_sync_states_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -97,6 +97,8 @@ model User {
|
||||
|
||||
subjects Subject[]
|
||||
studyLogs StudyLog[]
|
||||
psBookmarks PsBookmark[]
|
||||
psSyncState PsSyncState?
|
||||
reviewSchedules ReviewSchedule[]
|
||||
skillSnapshots SkillSnapshot[]
|
||||
organizationMembers OrganizationMember[]
|
||||
@@ -139,15 +141,17 @@ model Tag {
|
||||
}
|
||||
|
||||
model StudyLog {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
problemId Int?
|
||||
problem Problem? @relation(fields: [problemId], references: [id], onDelete: SetNull)
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
subjectId Int
|
||||
subject Subject @relation(fields: [subjectId], references: [id])
|
||||
tagId Int?
|
||||
tag Tag? @relation(fields: [tagId], references: [id], onDelete: SetNull)
|
||||
problemId Int?
|
||||
problem Problem? @relation(fields: [problemId], references: [id], onDelete: SetNull)
|
||||
psProblemId Int?
|
||||
psProblem PsProblem? @relation(fields: [psProblemId], references: [id], onDelete: SetNull)
|
||||
|
||||
title String
|
||||
difficulty Float
|
||||
@@ -164,6 +168,7 @@ model StudyLog {
|
||||
@@index([subjectId])
|
||||
@@index([tagId])
|
||||
@@index([problemId])
|
||||
@@index([psProblemId])
|
||||
@@map("study_logs")
|
||||
}
|
||||
|
||||
@@ -375,3 +380,49 @@ model SkillSnapshot {
|
||||
@@index([userId])
|
||||
@@map("skill_snapshots")
|
||||
}
|
||||
|
||||
model PsProblem {
|
||||
id Int @id @default(autoincrement())
|
||||
bojId Int @unique
|
||||
title String
|
||||
titleKo String?
|
||||
level Int
|
||||
tags Json?
|
||||
acceptedUserCount Int?
|
||||
averageTries Float?
|
||||
solvedacUpdatedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
bookmarks PsBookmark[]
|
||||
studyLogs StudyLog[]
|
||||
|
||||
@@index([level])
|
||||
@@map("ps_problems")
|
||||
}
|
||||
|
||||
model PsBookmark {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
psProblemId Int
|
||||
psProblem PsProblem @relation(fields: [psProblemId], references: [id], onDelete: Cascade)
|
||||
memo String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, psProblemId])
|
||||
@@index([userId])
|
||||
@@map("ps_bookmarks")
|
||||
}
|
||||
|
||||
model PsSyncState {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int @unique
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lastSyncedAt DateTime?
|
||||
lastSolvedCount Int @default(0)
|
||||
lastTier Int?
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("ps_sync_states")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ProblemSetsModule } from "./problem-sets/problem-sets.module";
|
||||
import { OrganizationsModule } from "./organizations/organizations.module";
|
||||
import { ClassesModule } from "./classes/classes.module";
|
||||
import { AssignmentsModule } from "./assignments/assignments.module";
|
||||
import { PsModule } from "./ps/ps.module";
|
||||
|
||||
class AppThrottlerGuard extends ThrottlerGuard {
|
||||
protected async throwThrottlingException(): Promise<void> {
|
||||
@@ -49,6 +50,7 @@ class AppThrottlerGuard extends ThrottlerGuard {
|
||||
OrganizationsModule,
|
||||
ClassesModule,
|
||||
AssignmentsModule,
|
||||
PsModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { OrgRole, Prisma } from '@prisma/client';
|
||||
import { MathUnit, OrgRole, Prisma } from '@prisma/client';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -80,6 +80,12 @@ export class AuthService {
|
||||
return this.jwt.sign(payload);
|
||||
}
|
||||
|
||||
private normalizeFocusUnits(value: Prisma.JsonValue | null): MathUnit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const allowed = new Set(Object.values(MathUnit));
|
||||
return value.filter((item): item is MathUnit => typeof item === 'string' && allowed.has(item as MathUnit));
|
||||
}
|
||||
|
||||
private safeUser(
|
||||
u: {
|
||||
id: number;
|
||||
@@ -91,6 +97,8 @@ export class AuthService {
|
||||
targetGrade: number | null;
|
||||
targetExamYear: number | null;
|
||||
focusSubjects: Prisma.JsonValue | null;
|
||||
focusUnits: Prisma.JsonValue | null;
|
||||
bojHandle: string | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
subscriptionTier: string;
|
||||
@@ -111,6 +119,8 @@ export class AuthService {
|
||||
focusSubjects: Array.isArray(u.focusSubjects)
|
||||
? u.focusSubjects.filter((subject): subject is string => typeof subject === 'string')
|
||||
: [],
|
||||
focusUnits: this.normalizeFocusUnits(u.focusUnits),
|
||||
bojHandle: u.bojHandle ?? null,
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
subscriptionTier: u.subscriptionTier,
|
||||
|
||||
@@ -20,10 +20,11 @@ import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { Persona, ReviewIntensity } from '@prisma/client';
|
||||
import { MathUnit, Persona, ReviewIntensity } from '@prisma/client';
|
||||
import { extname, join } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
@@ -70,6 +71,16 @@ class OnboardingDto {
|
||||
|
||||
@IsEnum(ReviewIntensity)
|
||||
reviewIntensity: ReviewIntensity;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(4)
|
||||
@IsEnum(MathUnit, { each: true })
|
||||
focusUnits?: MathUnit[];
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^[a-zA-Z0-9_-]{3,20}$/)
|
||||
bojHandle?: string;
|
||||
}
|
||||
|
||||
class ProfilePatchDto {
|
||||
@@ -113,6 +124,16 @@ class ProfilePatchDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
onboarded?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(4)
|
||||
@IsEnum(MathUnit, { each: true })
|
||||
focusUnits?: MathUnit[];
|
||||
|
||||
@IsOptional()
|
||||
@Matches(/^[a-zA-Z0-9_-]{3,20}$/)
|
||||
bojHandle?: string | null;
|
||||
}
|
||||
|
||||
@Controller('me')
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Persona, Prisma, ReviewIntensity } from '@prisma/client';
|
||||
import { MathUnit, Persona, Prisma, ReviewIntensity } from '@prisma/client';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -14,6 +14,8 @@ export interface OnboardingInput {
|
||||
targetExamYear?: number;
|
||||
focusSubjects?: string[];
|
||||
reviewIntensity: ReviewIntensity;
|
||||
focusUnits?: MathUnit[];
|
||||
bojHandle?: string;
|
||||
}
|
||||
|
||||
interface ProfileInput {
|
||||
@@ -25,6 +27,8 @@ interface ProfileInput {
|
||||
focusSubjects?: string[];
|
||||
reviewIntensity?: ReviewIntensity;
|
||||
onboarded?: boolean;
|
||||
focusUnits?: MathUnit[];
|
||||
bojHandle?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -40,6 +44,8 @@ export class MeService {
|
||||
targetGrade: input.targetGrade ?? null,
|
||||
targetExamYear: input.targetExamYear ?? null,
|
||||
focusSubjects: input.focusSubjects ?? [],
|
||||
focusUnits: input.focusUnits ?? [],
|
||||
bojHandle: input.bojHandle ?? null,
|
||||
reviewIntensity: input.reviewIntensity,
|
||||
onboardedAt: new Date(),
|
||||
},
|
||||
@@ -58,6 +64,8 @@ export class MeService {
|
||||
if (input.focusSubjects !== undefined) data.focusSubjects = input.focusSubjects;
|
||||
if (input.reviewIntensity !== undefined) data.reviewIntensity = input.reviewIntensity;
|
||||
if (input.onboarded) data.onboardedAt = new Date();
|
||||
if (input.focusUnits !== undefined) data.focusUnits = input.focusUnits;
|
||||
if (input.bojHandle !== undefined) data.bojHandle = input.bojHandle ?? null;
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
@@ -129,6 +137,8 @@ export class MeService {
|
||||
targetGrade: number | null;
|
||||
targetExamYear: number | null;
|
||||
focusSubjects: Prisma.JsonValue | null;
|
||||
focusUnits: Prisma.JsonValue | null;
|
||||
bojHandle: string | null;
|
||||
reviewIntensity: string;
|
||||
onboardedAt: Date | null;
|
||||
subscriptionTier: string;
|
||||
@@ -147,6 +157,8 @@ export class MeService {
|
||||
focusSubjects: Array.isArray(u.focusSubjects)
|
||||
? u.focusSubjects.filter((subject): subject is string => typeof subject === 'string')
|
||||
: [],
|
||||
focusUnits: this.normalizeFocusUnits(u.focusUnits),
|
||||
bojHandle: u.bojHandle ?? null,
|
||||
reviewIntensity: u.reviewIntensity,
|
||||
onboarded: u.onboardedAt !== null,
|
||||
subscriptionTier: u.subscriptionTier,
|
||||
@@ -155,6 +167,12 @@ export class MeService {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeFocusUnits(value: Prisma.JsonValue | null): MathUnit[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const valid = new Set(Object.values(MathUnit));
|
||||
return value.filter((item): item is MathUnit => typeof item === 'string' && valid.has(item as MathUnit));
|
||||
}
|
||||
|
||||
private async deleteAvatarFile(avatarUrl: string | null) {
|
||||
if (!avatarUrl?.startsWith('/uploads/')) return;
|
||||
|
||||
|
||||
108
backend/src/ps/ps.controller.ts
Normal file
108
backend/src/ps/ps.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { IsInt, IsOptional, IsString, Matches, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { AuthUser } from '../auth/jwt.strategy';
|
||||
import { PsService } from './ps.service';
|
||||
|
||||
class SearchQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(30)
|
||||
level?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
}
|
||||
|
||||
class BookmarkDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
bojId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
class SyncRequestDto {
|
||||
@IsOptional()
|
||||
@Matches(/^[a-zA-Z0-9_-]{3,20}$/)
|
||||
bojHandle?: string;
|
||||
}
|
||||
|
||||
@Controller('ps')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PsController {
|
||||
constructor(private readonly ps: PsService) {}
|
||||
|
||||
@Get('search')
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
search(@Query() query: SearchQueryDto) {
|
||||
return this.ps.search({
|
||||
query: query.q,
|
||||
level: query.level,
|
||||
tag: query.tag,
|
||||
page: query.page,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('problems/:bojId')
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
getProblem(@Param('bojId', ParseIntPipe) bojId: number) {
|
||||
return this.ps.getProblem(bojId);
|
||||
}
|
||||
|
||||
@Get('bookmarks')
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
listBookmarks(@CurrentUser() user: AuthUser) {
|
||||
return this.ps.listBookmarks(user.id);
|
||||
}
|
||||
|
||||
@Post('bookmarks')
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
bookmark(@CurrentUser() user: AuthUser, @Body() dto: BookmarkDto) {
|
||||
return this.ps.bookmark(user.id, dto.bojId, dto.memo);
|
||||
}
|
||||
|
||||
@Delete('bookmarks/:bojId')
|
||||
@Throttle({ default: { limit: 30, ttl: 60_000 } })
|
||||
async removeBookmark(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('bojId', ParseIntPipe) bojId: number,
|
||||
) {
|
||||
await this.ps.removeBookmark(user.id, bojId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('sync')
|
||||
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
||||
sync(@CurrentUser() user: AuthUser, @Body() dto: SyncRequestDto) {
|
||||
return this.ps.sync(user.id, dto.bojHandle);
|
||||
}
|
||||
}
|
||||
14
backend/src/ps/ps.module.ts
Normal file
14
backend/src/ps/ps.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { StudyLogsModule } from '../study-logs/study-logs.module';
|
||||
import { PsController } from './ps.controller';
|
||||
import { PsService } from './ps.service';
|
||||
import SolvedAcClient from './solved-ac.client';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StudyLogsModule],
|
||||
controllers: [PsController],
|
||||
providers: [PsService, SolvedAcClient],
|
||||
exports: [PsService],
|
||||
})
|
||||
export class PsModule {}
|
||||
246
backend/src/ps/ps.service.ts
Normal file
246
backend/src/ps/ps.service.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma, PsBookmark, PsProblem, StudyResult } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import SolvedAcClient, {
|
||||
SolvedAcClientError,
|
||||
type SolvedAcProblem,
|
||||
type SolvedAcSearchResponse,
|
||||
type SolvedAcUser,
|
||||
type SolvedAcUserProblemStat,
|
||||
} from './solved-ac.client';
|
||||
import { StudyLogsService } from '../study-logs/study-logs.service';
|
||||
|
||||
interface SearchParams {
|
||||
query?: string;
|
||||
level?: number;
|
||||
tag?: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PsService {
|
||||
private static readonly SUBJECT_NAME = 'PS';
|
||||
private static readonly SUBJECT_COLOR = '#10b981';
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly solvedAc: SolvedAcClient,
|
||||
private readonly studyLogs: StudyLogsService,
|
||||
) {}
|
||||
|
||||
async search(params: SearchParams): Promise<SolvedAcSearchResponse> {
|
||||
try {
|
||||
const response = await this.solvedAc.searchProblems(params);
|
||||
await Promise.all(response.items.map((item) => this.upsertPsProblem(item)));
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw this.translateClientError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getProblem(bojId: number): Promise<PsProblem> {
|
||||
const cached = await this.prisma.psProblem.findUnique({
|
||||
where: { bojId },
|
||||
});
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const problem = await this.solvedAc.getProblem(bojId);
|
||||
return this.upsertPsProblem(problem);
|
||||
} catch (error) {
|
||||
throw this.translateClientError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bookmark(userId: number, bojId: number, memo?: string): Promise<PsBookmark> {
|
||||
const psProblem = await this.getProblem(bojId);
|
||||
return this.prisma.psBookmark.upsert({
|
||||
where: {
|
||||
userId_psProblemId: {
|
||||
userId,
|
||||
psProblemId: psProblem.id,
|
||||
},
|
||||
},
|
||||
update: { memo: memo ?? null },
|
||||
create: {
|
||||
userId,
|
||||
psProblemId: psProblem.id,
|
||||
memo: memo ?? null,
|
||||
},
|
||||
include: { psProblem: true },
|
||||
});
|
||||
}
|
||||
|
||||
async removeBookmark(userId: number, bojId: number) {
|
||||
await this.prisma.psBookmark.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
psProblem: { bojId },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listBookmarks(userId: number) {
|
||||
return this.prisma.psBookmark.findMany({
|
||||
where: { userId },
|
||||
include: { psProblem: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async sync(userId: number, handleOverride?: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } });
|
||||
const requestedHandle = handleOverride?.trim();
|
||||
const storedHandle = user.bojHandle?.trim();
|
||||
const handle = requestedHandle || storedHandle;
|
||||
if (!handle) throw new BadRequestException('bojHandle is required');
|
||||
|
||||
if (requestedHandle && requestedHandle !== storedHandle) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { bojHandle: requestedHandle },
|
||||
});
|
||||
}
|
||||
|
||||
const syncState = await this.prisma.psSyncState.upsert({
|
||||
where: { userId },
|
||||
create: { userId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
let stats: SolvedAcUserProblemStat[];
|
||||
let topProblems: SolvedAcProblem[];
|
||||
let profile: SolvedAcUser;
|
||||
try {
|
||||
[stats, topProblems, profile] = await Promise.all([
|
||||
this.solvedAc.getUserProblemStats(handle),
|
||||
this.solvedAc.getUserTop100(handle),
|
||||
this.solvedAc.getUser(handle),
|
||||
]);
|
||||
} catch (error) {
|
||||
throw this.translateClientError(error);
|
||||
}
|
||||
|
||||
const solvedFromStats = stats.reduce((sum, entry) => sum + (entry.solved ?? 0), 0);
|
||||
const totalSolved = Math.max(profile?.solvedCount ?? 0, solvedFromStats);
|
||||
const previousSolved = syncState.lastSolvedCount ?? 0;
|
||||
const diff = Math.max(0, totalSolved - previousSolved);
|
||||
const subject = await this.ensurePsSubject(userId);
|
||||
|
||||
const limit = previousSolved === 0 ? topProblems.length : Math.min(diff, topProblems.length);
|
||||
const targetProblems = topProblems.slice(0, limit);
|
||||
|
||||
const psRecords = await Promise.all(targetProblems.map((problem) => this.upsertPsProblem(problem)));
|
||||
const psProblemIds = psRecords.map((record) => record.id);
|
||||
const existingIds = new Set<number>();
|
||||
if (psProblemIds.length > 0) {
|
||||
const existing = await this.prisma.studyLog.findMany({
|
||||
where: {
|
||||
userId,
|
||||
psProblemId: { in: psProblemIds },
|
||||
},
|
||||
select: { psProblemId: true },
|
||||
});
|
||||
for (const row of existing) {
|
||||
if (row.psProblemId) existingIds.add(row.psProblemId);
|
||||
}
|
||||
}
|
||||
|
||||
let importedCount = 0;
|
||||
for (const record of psRecords) {
|
||||
if (existingIds.has(record.id)) continue;
|
||||
await this.studyLogs.create(userId, {
|
||||
subjectId: subject.id,
|
||||
title: record.titleKo ?? record.title,
|
||||
difficulty: this.normalizeDifficulty(record.level),
|
||||
result: StudyResult.correct,
|
||||
psProblemId: record.id,
|
||||
});
|
||||
importedCount += 1;
|
||||
}
|
||||
const skippedCount = psRecords.length - importedCount;
|
||||
|
||||
await this.prisma.psSyncState.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
lastSolvedCount: totalSolved,
|
||||
lastTier: profile?.tier ?? null,
|
||||
lastSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
handle,
|
||||
importedCount,
|
||||
skippedCount,
|
||||
totalSolved,
|
||||
};
|
||||
}
|
||||
|
||||
private async upsertPsProblem(problem: SolvedAcProblem) {
|
||||
const title = problem.title ?? problem.titleKo ?? `BOJ ${problem.problemId}`;
|
||||
return this.prisma.psProblem.upsert({
|
||||
where: { bojId: problem.problemId },
|
||||
update: {
|
||||
title,
|
||||
titleKo: problem.titleKo ?? null,
|
||||
level: problem.level ?? 0,
|
||||
tags: this.serializeTags(problem.tags),
|
||||
acceptedUserCount: problem.acceptedUserCount ?? null,
|
||||
averageTries: problem.averageTries ?? null,
|
||||
solvedacUpdatedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
bojId: problem.problemId,
|
||||
title,
|
||||
titleKo: problem.titleKo ?? null,
|
||||
level: problem.level ?? 0,
|
||||
tags: this.serializeTags(problem.tags),
|
||||
acceptedUserCount: problem.acceptedUserCount ?? null,
|
||||
averageTries: problem.averageTries ?? null,
|
||||
solvedacUpdatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async ensurePsSubject(userId: number) {
|
||||
return this.prisma.subject.upsert({
|
||||
where: {
|
||||
userId_name: {
|
||||
userId,
|
||||
name: PsService.SUBJECT_NAME,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId,
|
||||
name: PsService.SUBJECT_NAME,
|
||||
color: PsService.SUBJECT_COLOR,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeDifficulty(level: number) {
|
||||
const ratio = (level ?? 0) / 30;
|
||||
return Math.min(1, Math.max(0.1, ratio));
|
||||
}
|
||||
|
||||
private serializeTags(tags?: SolvedAcProblem['tags']) {
|
||||
if (!tags) return Prisma.JsonNull;
|
||||
return tags as unknown as Prisma.JsonValue;
|
||||
}
|
||||
|
||||
private translateClientError(error: unknown): never {
|
||||
if (error instanceof SolvedAcClientError) {
|
||||
if (error.statusCode === 404) {
|
||||
throw new NotFoundException('Solved.ac resource not found');
|
||||
}
|
||||
throw new ServiceUnavailableException('Solved.ac API is unavailable');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
126
backend/src/ps/solved-ac.client.spec.ts
Normal file
126
backend/src/ps/solved-ac.client.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import axios from 'axios';
|
||||
import SolvedAcClient, {
|
||||
SolvedAcClientError,
|
||||
type SolvedAcProblem,
|
||||
type SolvedAcSearchResponse,
|
||||
type SolvedAcUser,
|
||||
} from './solved-ac.client';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const mockedAxios = axios as unknown as {
|
||||
create: vi.Mock;
|
||||
isAxiosError: vi.Mock;
|
||||
};
|
||||
|
||||
describe('SolvedAcClient', () => {
|
||||
const mockGet = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockedAxios.create.mockReturnValue({
|
||||
get: mockGet,
|
||||
interceptors: {
|
||||
request: { use: vi.fn() },
|
||||
response: { use: vi.fn() },
|
||||
},
|
||||
});
|
||||
mockedAxios.isAxiosError.mockImplementation(
|
||||
(error: unknown): error is Error & { response?: { status?: number } } =>
|
||||
typeof error === 'object' && error !== null && 'response' in error,
|
||||
);
|
||||
});
|
||||
|
||||
it('searchProblems returns parsed response', async () => {
|
||||
const response: SolvedAcSearchResponse = {
|
||||
count: 1,
|
||||
items: [{ problemId: 1000, level: 5, title: 'Two Sum' }],
|
||||
};
|
||||
mockGet.mockResolvedValue({ data: response });
|
||||
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
const result = await client.searchProblems({ query: 'dp', level: 5, tag: 'math', page: 2 });
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/search/problem', {
|
||||
params: { query: 'dp lv:5 #math', page: 2 },
|
||||
});
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('getProblem hits problem endpoint', async () => {
|
||||
const problem: SolvedAcProblem = { problemId: 2557, level: 1, title: 'Hello World' };
|
||||
mockGet.mockResolvedValue({ data: problem });
|
||||
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
const result = await client.getProblem(2557);
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/problem/show', {
|
||||
params: { problemId: 2557 },
|
||||
});
|
||||
expect(result).toEqual(problem);
|
||||
});
|
||||
|
||||
it('getUser returns user profile', async () => {
|
||||
const user: SolvedAcUser = { handle: 'reloop', solvedCount: 1234, tier: 15 };
|
||||
mockGet.mockResolvedValue({ data: user });
|
||||
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
const result = await client.getUser('reloop');
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/user/show', { params: { handle: 'reloop' } });
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
|
||||
it('getUserTop100 unwraps items array', async () => {
|
||||
const items: SolvedAcProblem[] = [
|
||||
{ problemId: 1000, level: 10, title: 'BOJ 1000' },
|
||||
{ problemId: 1001, level: 12, title: 'BOJ 1001' },
|
||||
];
|
||||
mockGet.mockResolvedValue({ data: { items } });
|
||||
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
const result = await client.getUserTop100('reloop');
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/user/top_100', { params: { handle: 'reloop' } });
|
||||
expect(result).toEqual(items);
|
||||
});
|
||||
|
||||
it('getUserProblemStats returns data array', async () => {
|
||||
const stats = [
|
||||
{ level: 5, total: 100, solved: 50 },
|
||||
{ level: 10, total: 80, solved: 20 },
|
||||
];
|
||||
mockGet.mockResolvedValue({ data: stats });
|
||||
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
const result = await client.getUserProblemStats('reloop');
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('/user/problem_stats', { params: { handle: 'reloop' } });
|
||||
expect(result).toEqual(stats);
|
||||
});
|
||||
|
||||
it('retries on 429 responses and eventually succeeds', async () => {
|
||||
mockGet
|
||||
.mockRejectedValueOnce({ response: { status: 429 }, message: 'rate limit' })
|
||||
.mockResolvedValueOnce({
|
||||
data: { count: 0, items: [] } satisfies SolvedAcSearchResponse,
|
||||
});
|
||||
|
||||
const sleepSpy = vi.fn().mockResolvedValue(undefined);
|
||||
const client = new SolvedAcClient({ sleep: sleepSpy });
|
||||
|
||||
const result = await client.searchProblems({ query: 'graph' });
|
||||
|
||||
expect(result.items).toEqual([]);
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
expect(sleepSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws SolvedAcClientError on unrecoverable error', async () => {
|
||||
mockGet.mockRejectedValue({ response: { status: 404 }, message: 'not found' });
|
||||
const client = new SolvedAcClient({ sleep: () => Promise.resolve() });
|
||||
|
||||
await expect(client.getProblem(999999)).rejects.toBeInstanceOf(SolvedAcClientError);
|
||||
});
|
||||
});
|
||||
228
backend/src/ps/solved-ac.client.ts
Normal file
228
backend/src/ps/solved-ac.client.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import axios, { AxiosHeaders, AxiosInstance } from 'axios';
|
||||
|
||||
export interface SolvedAcTag {
|
||||
key: string;
|
||||
displayNames?: Array<{ language: string; name: string }>;
|
||||
}
|
||||
|
||||
export interface SolvedAcProblem {
|
||||
problemId: number;
|
||||
title?: string | null;
|
||||
titleKo?: string | null;
|
||||
level: number;
|
||||
tags?: SolvedAcTag[];
|
||||
acceptedUserCount?: number | null;
|
||||
averageTries?: number | null;
|
||||
}
|
||||
|
||||
export interface SolvedAcSearchResponse {
|
||||
count: number;
|
||||
items: SolvedAcProblem[];
|
||||
}
|
||||
|
||||
export interface SolvedAcUser {
|
||||
handle: string;
|
||||
solvedCount: number;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
export interface SolvedAcUserProblemStat {
|
||||
level: number;
|
||||
total: number;
|
||||
solved: number;
|
||||
}
|
||||
|
||||
export interface SolvedAcClientOptions {
|
||||
maxRequestsPerSecond?: number;
|
||||
timeout?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
interface QueueItem<T> {
|
||||
task: () => Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason: unknown) => void;
|
||||
}
|
||||
|
||||
export class SolvedAcClientError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SolvedAcClientError';
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export default class SolvedAcClient {
|
||||
private readonly http: AxiosInstance;
|
||||
private readonly queue: QueueItem<unknown>[] = [];
|
||||
private processing = false;
|
||||
private tokens: number;
|
||||
private lastRefill = Date.now();
|
||||
private readonly maxRequestsPerSecond: number;
|
||||
private readonly sleep: (ms: number) => Promise<void>;
|
||||
private readonly maxRetries = 3;
|
||||
|
||||
constructor(options: SolvedAcClientOptions = {}) {
|
||||
this.maxRequestsPerSecond = options.maxRequestsPerSecond ?? 3;
|
||||
this.tokens = this.maxRequestsPerSecond;
|
||||
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
|
||||
this.http = axios.create({
|
||||
baseURL: 'https://solved.ac/api/v3',
|
||||
timeout: options.timeout ?? 10_000,
|
||||
headers: {
|
||||
'User-Agent': 'ReLoop/2.0 (+https://reloop.nabomhalang.co.kr)',
|
||||
},
|
||||
});
|
||||
|
||||
this.http.interceptors.request.use((config) => {
|
||||
const headers = AxiosHeaders.from(config.headers);
|
||||
headers.set('User-Agent', 'ReLoop/2.0 (+https://reloop.nabomhalang.co.kr)');
|
||||
config.headers = headers;
|
||||
return config;
|
||||
});
|
||||
this.http.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => Promise.reject(this.toClientError(error)),
|
||||
);
|
||||
}
|
||||
|
||||
async searchProblems(params: {
|
||||
query?: string;
|
||||
level?: number;
|
||||
tag?: string;
|
||||
page?: number;
|
||||
}): Promise<SolvedAcSearchResponse> {
|
||||
const query = this.buildSearchQuery(params);
|
||||
return this.enqueue(() =>
|
||||
this.runWithRetry(() =>
|
||||
this.http
|
||||
.get<SolvedAcSearchResponse>('/search/problem', {
|
||||
params: { query, page: params.page ?? 1 },
|
||||
})
|
||||
.then((res) => res.data),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getProblem(bojId: number): Promise<SolvedAcProblem> {
|
||||
return this.enqueue(() =>
|
||||
this.runWithRetry(() =>
|
||||
this.http
|
||||
.get<SolvedAcProblem>('/problem/show', { params: { problemId: bojId } })
|
||||
.then((res) => res.data),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getUser(handle: string): Promise<SolvedAcUser> {
|
||||
return this.enqueue(() =>
|
||||
this.runWithRetry(() =>
|
||||
this.http
|
||||
.get<SolvedAcUser>('/user/show', { params: { handle } })
|
||||
.then((res) => res.data),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserProblemStats(handle: string): Promise<SolvedAcUserProblemStat[]> {
|
||||
return this.enqueue(() =>
|
||||
this.runWithRetry(() =>
|
||||
this.http
|
||||
.get<SolvedAcUserProblemStat[]>('/user/problem_stats', { params: { handle } })
|
||||
.then((res) => res.data),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserTop100(handle: string): Promise<SolvedAcProblem[]> {
|
||||
return this.enqueue(() =>
|
||||
this.runWithRetry(() =>
|
||||
this.http
|
||||
.get<{ items: SolvedAcProblem[] }>('/user/top_100', { params: { handle } })
|
||||
.then((res) => res.data.items ?? []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private buildSearchQuery(params: { query?: string; level?: number; tag?: string }) {
|
||||
const parts: string[] = [];
|
||||
if (params.query?.trim()) parts.push(params.query.trim());
|
||||
if (params.level !== undefined) parts.push(`lv:${params.level}`);
|
||||
if (params.tag?.trim()) {
|
||||
const tag = params.tag.startsWith('#') ? params.tag.trim() : `#${params.tag.trim()}`;
|
||||
parts.push(tag);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' ') : '*';
|
||||
}
|
||||
|
||||
private async enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
this.queue.push({ task, resolve, reject });
|
||||
void this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.processing) return;
|
||||
this.processing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const now = Date.now();
|
||||
if (now - this.lastRefill >= 1000) {
|
||||
this.tokens = this.maxRequestsPerSecond;
|
||||
this.lastRefill = now;
|
||||
}
|
||||
|
||||
if (this.tokens <= 0) {
|
||||
const wait = Math.max(0, 1000 - (now - this.lastRefill));
|
||||
await this.sleep(wait);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = this.queue.shift()!;
|
||||
this.tokens -= 1;
|
||||
try {
|
||||
const result = await item.task();
|
||||
item.resolve(result);
|
||||
} catch (error) {
|
||||
item.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
|
||||
private async runWithRetry<T>(fn: () => Promise<T>, attempt = 0): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
const clientError = error instanceof SolvedAcClientError ? error : this.toClientError(error);
|
||||
if (
|
||||
attempt < this.maxRetries &&
|
||||
clientError.statusCode !== undefined &&
|
||||
(clientError.statusCode === 429 || clientError.statusCode >= 500)
|
||||
) {
|
||||
const delay = Math.pow(2, attempt) * 250;
|
||||
await this.sleep(delay);
|
||||
return this.runWithRetry(fn, attempt + 1);
|
||||
}
|
||||
throw clientError;
|
||||
}
|
||||
}
|
||||
|
||||
private toClientError(error: unknown) {
|
||||
if (error instanceof SolvedAcClientError) return error;
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Solved.ac request failed';
|
||||
return new SolvedAcClientError(message, status, error);
|
||||
}
|
||||
return new SolvedAcClientError('Solved.ac request failed', undefined, error);
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,6 @@ import { StudyLogsService } from './study-logs.service';
|
||||
@Module({
|
||||
controllers: [StudyLogsController],
|
||||
providers: [StudyLogsService],
|
||||
exports: [StudyLogsService],
|
||||
})
|
||||
export class StudyLogsModule {}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CreateStudyLogInput {
|
||||
subjectId: number;
|
||||
tagId?: number;
|
||||
problemId?: number;
|
||||
psProblemId?: number;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
baseCorrectRate?: number | null;
|
||||
@@ -436,6 +437,13 @@ export class StudyLogsService {
|
||||
});
|
||||
if (!problem) throw new NotFoundException("problem");
|
||||
}
|
||||
|
||||
if (input.psProblemId) {
|
||||
const psProblem = await db.psProblem.findUnique({
|
||||
where: { id: input.psProblemId },
|
||||
});
|
||||
if (!psProblem) throw new NotFoundException("psProblem");
|
||||
}
|
||||
}
|
||||
|
||||
private async createInTransaction(
|
||||
@@ -456,6 +464,7 @@ export class StudyLogsService {
|
||||
subjectId: input.subjectId,
|
||||
tagId: input.tagId ?? null,
|
||||
problemId: input.problemId ?? null,
|
||||
psProblemId: input.psProblemId ?? null,
|
||||
title: input.title,
|
||||
difficulty: clamp01(input.difficulty),
|
||||
baseCorrectRate: input.baseCorrectRate ?? null,
|
||||
|
||||
Reference in New Issue
Block a user