fix: confirm module shutdown before disconnect or release

This commit is contained in:
2026-08-04 13:11:26 +09:00
parent b7e910dcf7
commit e06b853bcb
8 changed files with 506 additions and 3 deletions
+26 -1
View File
@@ -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));
+138 -2
View File
@@ -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;