fix: confirm module shutdown before disconnect or release
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import { CurriculumService } from './curriculum.service';
|
||||
@@ -15,6 +15,31 @@ export class CurriculumController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('custom-modules')
|
||||
createCustomModule(
|
||||
@Req() request: Request,
|
||||
@Body() body: {
|
||||
courseId?: string;
|
||||
week?: number;
|
||||
name?: string;
|
||||
category?: string;
|
||||
dockerImage?: string;
|
||||
packageName?: string;
|
||||
launchFile?: string;
|
||||
},
|
||||
) {
|
||||
return this.curriculumService.createCustomModule(this.getBearerToken(request), body);
|
||||
}
|
||||
|
||||
@Delete('custom-modules/:moduleId')
|
||||
deleteCustomModule(
|
||||
@Req() request: Request,
|
||||
@Param('moduleId') moduleId: string,
|
||||
@Query('courseId') courseId?: string,
|
||||
) {
|
||||
return this.curriculumService.deleteCustomModule(this.getBearerToken(request), moduleId, courseId);
|
||||
}
|
||||
|
||||
@Get('courses')
|
||||
listCourses(@Req() request: Request) {
|
||||
return this.curriculumService.listCourses(this.getBearerToken(request));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { join } from 'node:path';
|
||||
|
||||
@@ -10,6 +10,9 @@ import { serializeCourseMaps } from './course-maps';
|
||||
|
||||
const COURSE_REFERENCE_UPLOAD_ROOT = join(process.cwd(), 'uploads', 'course-references');
|
||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
|
||||
const CUSTOM_MODULE_CATEGORIES = new Set([
|
||||
'Vehicle', 'Sensing', 'Map', 'Perception', 'Localization', 'Planning', 'Control', 'System', 'Simulator', 'Gui',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class CurriculumService {
|
||||
@@ -181,6 +184,19 @@ export class CurriculumService {
|
||||
return emptyCurriculumResponse();
|
||||
}
|
||||
|
||||
const customModules = lmsUser
|
||||
? await this.prisma.studentCustomModule.findMany({
|
||||
where: { userId: lmsUser.id, courseId: course.id },
|
||||
orderBy: [{ week: 'asc' }, { sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
const customModulesByWeek = new Map<number, typeof customModules>();
|
||||
customModules.forEach((module) => {
|
||||
const items = customModulesByWeek.get(module.week) ?? [];
|
||||
items.push(module);
|
||||
customModulesByWeek.set(module.week, items);
|
||||
});
|
||||
|
||||
const visualizationSelections = await this.prisma.courseWeekVisualizationPlatform.findMany({
|
||||
where: {
|
||||
courseId: course.id,
|
||||
@@ -421,7 +437,8 @@ export class CurriculumService {
|
||||
sourceSubmitted: Boolean(weekProgress?.submittedAt),
|
||||
sourceSubmittedAt: weekProgress?.submittedAt?.toISOString() ?? null,
|
||||
},
|
||||
modules: week.modules
|
||||
modules: [
|
||||
...week.modules
|
||||
.filter((module) => !disabledModuleIdSet.has(module.id))
|
||||
.map((module) => {
|
||||
const moduleWithArtifacts = module as typeof module & {
|
||||
@@ -442,6 +459,21 @@ export class CurriculumService {
|
||||
dockerImage: module.dockerImage,
|
||||
};
|
||||
}),
|
||||
...(customModulesByWeek.get(week.week) ?? []).map((module) => ({
|
||||
id: module.id,
|
||||
sortOrder: module.sortOrder,
|
||||
category: module.category,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
templateRepositoryUrl: null,
|
||||
sourceType: 'CUSTOM',
|
||||
prebuiltArtifacts: [],
|
||||
defaultParameters: [],
|
||||
dockerImage: module.dockerImage,
|
||||
packageName: module.packageName,
|
||||
launchFile: module.launchFile,
|
||||
})),
|
||||
],
|
||||
environmentProfiles: visualizationProfilesByWeek.get(week.week) ?? [],
|
||||
maps: serializeCourseMaps(mapKeysByWeek.get(week.week) ?? [], course.maps),
|
||||
};
|
||||
@@ -547,6 +579,110 @@ export class CurriculumService {
|
||||
};
|
||||
}
|
||||
|
||||
async createCustomModule(
|
||||
accessToken: string | undefined,
|
||||
input: {
|
||||
courseId?: string;
|
||||
week?: number;
|
||||
name?: string;
|
||||
category?: string;
|
||||
dockerImage?: string;
|
||||
packageName?: string;
|
||||
launchFile?: string;
|
||||
},
|
||||
) {
|
||||
const { user: authUser } = await this.authService.me(accessToken);
|
||||
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||
const user = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||
if (!user) throw new UnauthorizedException('LMS user not found');
|
||||
|
||||
const courseId = input.courseId?.trim() ?? '';
|
||||
const week = Number(input.week);
|
||||
const name = input.name?.trim() ?? '';
|
||||
const category = input.category?.trim() ?? '';
|
||||
const dockerImage = input.dockerImage?.trim() || null;
|
||||
const packageName = input.packageName?.trim() ?? '';
|
||||
const launchFile = input.launchFile?.trim() ?? '';
|
||||
if (!courseId || !Number.isInteger(week) || week < 1 || !name || !packageName || !launchFile) {
|
||||
throw new BadRequestException('모듈 정보가 올바르지 않습니다.');
|
||||
}
|
||||
if (name.length > 80 || !CUSTOM_MODULE_CATEGORIES.has(category)) {
|
||||
throw new BadRequestException('모듈 이름 또는 카테고리가 올바르지 않습니다.');
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]*$/.test(packageName) || !/^[a-zA-Z0-9_.-]+\.launch\.py$/.test(launchFile)) {
|
||||
throw new BadRequestException('ROS2 패키지명 또는 Launch 파일명이 올바르지 않습니다.');
|
||||
}
|
||||
if (dockerImage && (!/^[a-zA-Z0-9_./:@-]+$/.test(dockerImage) || dockerImage.length > 180)) {
|
||||
throw new BadRequestException('실행 이미지명이 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const enrollment = await this.prisma.studentEnrollment.findFirst({
|
||||
where: { userId: user.id, courseId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
});
|
||||
const courseWeek = await this.prisma.courseWeek.findFirst({
|
||||
where: { courseId, week },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!enrollment || !courseWeek) throw new NotFoundException('수강 중인 강좌 주차를 찾을 수 없습니다.');
|
||||
|
||||
const folderName = `[lms-nav2-custom]${packageName}_v1.0.0_ws`;
|
||||
const duplicateName = await this.prisma.studentCustomModule.findFirst({
|
||||
where: { userId: user.id, courseId, week, name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (duplicateName) throw new ConflictException('같은 이름의 커스텀 모듈이 이미 있습니다.');
|
||||
const count = await this.prisma.studentCustomModule.count({ where: { userId: user.id, courseId, week } });
|
||||
try {
|
||||
const module = await this.prisma.studentCustomModule.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
courseId,
|
||||
week,
|
||||
sortOrder: 10_000 + count,
|
||||
category,
|
||||
name,
|
||||
folderName,
|
||||
dockerImage,
|
||||
packageName,
|
||||
launchFile,
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: module.id,
|
||||
sortOrder: module.sortOrder,
|
||||
category: module.category,
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
dockerImage: module.dockerImage,
|
||||
packageName: module.packageName,
|
||||
launchFile: module.launchFile,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException('같은 패키지명의 커스텀 모듈이 이미 있습니다.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomModule(accessToken: string | undefined, moduleId: string, courseId?: string) {
|
||||
const { user: authUser } = await this.authService.me(accessToken);
|
||||
if (!authUser?.email) throw new UnauthorizedException('Invalid user');
|
||||
const user = await this.prisma.user.findUnique({ where: { email: authUser.email } });
|
||||
if (!user) throw new UnauthorizedException('LMS user not found');
|
||||
|
||||
const deleted = await this.prisma.studentCustomModule.deleteMany({
|
||||
where: {
|
||||
id: moduleId,
|
||||
userId: user.id,
|
||||
...(courseId?.trim() ? { courseId: courseId.trim() } : {}),
|
||||
},
|
||||
});
|
||||
if (!deleted.count) throw new NotFoundException('커스텀 모듈을 찾을 수 없습니다.');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private withControlState(
|
||||
platform: {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user