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:
reloop
2026-04-14 18:47:09 +09:00
parent d892d8298b
commit 6ceeee4f3f
15 changed files with 990 additions and 12 deletions

View File

@@ -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
View File

@@ -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:

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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: [
{

View File

@@ -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,

View File

@@ -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')

View File

@@ -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;

View 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);
}
}

View 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 {}

View 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;
}
}

View 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);
});
});

View 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);
}
}

View File

@@ -5,5 +5,6 @@ import { StudyLogsService } from './study-logs.service';
@Module({
controllers: [StudyLogsController],
providers: [StudyLogsService],
exports: [StudyLogsService],
})
export class StudyLogsModule {}

View File

@@ -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,