diff --git a/src/app.controller.ts b/src/app.controller.ts index e269a18..8f630e0 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ConfigService } from '@nestjs/config'; -@ApiTags('Root') +@ApiTags('Root (Health Check)') @Controller('/') export class AppController { private AI_SERVER_URL: string; @@ -18,7 +18,7 @@ export class AppController { } @ApiOperation({ summary: 'AI 헬스체크' }) - @Get('/') + @Get('/ai') async healthCheckAI(): Promise<{ message: string }> { const response = await fetch(`${this.AI_SERVER_URL}/`); if (response.status === 200) { diff --git a/src/app.module.ts b/src/app.module.ts index 8b4834c..8040da5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { VrVideo } from './domain/vr-video/entity/vr-video.entity'; import { VrVideoModule } from './domain/vr-video/vr-video.module'; import { AppController } from './app.controller'; import { SampleModule } from './domain/sample/sample.module'; +import { SampleVrResource } from './domain/sample/entity/sample-vr-resource.entity'; @Module({ imports: [ @@ -34,7 +35,7 @@ import { SampleModule } from './domain/sample/sample.module'; username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), - entities: [User, Group, Badge, VrResource, VrVideo], + entities: [User, Group, Badge, VrResource, VrVideo, SampleVrResource], migrations: [__dirname + '/src/migrations/*.ts'], autoLoadEntities: true, charset: 'utf8mb4', diff --git a/src/domain/group/repository/group.repository.ts b/src/domain/group/repository/group.repository.ts index 8d606d5..8b41fa3 100644 --- a/src/domain/group/repository/group.repository.ts +++ b/src/domain/group/repository/group.repository.ts @@ -12,6 +12,30 @@ export class GroupRepository extends Repository { super(repository.target, repository.manager); } + async isUserInGroup(userId: string, groupId: string): Promise { + // 1. is the user careRecipient? + const isCareRecipient = + ( + await this.findOne({ + where: { recipient: { id: userId } }, + }) + )?.id === groupId + ? true + : false; + + // 2. is the user careGiver? + const isCareGiver = + ( + await this.findOne({ + where: { givers: { id: userId } }, + }) + )?.id === groupId + ? true + : false; + + return isCareRecipient || isCareGiver ? true : false; + } + async findByCareGiverIdWithUsers(giverId: string): Promise { return await this.findOne({ where: { givers: { id: giverId } }, diff --git a/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts b/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts index ee396aa..1242b93 100644 --- a/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts +++ b/src/domain/vr-resource/dto/response/get-vr-resources.response.dto.ts @@ -9,7 +9,8 @@ export class VrResourceDto extends PickType(VrResource, [ 'createdAt', ] as const) { @ApiProperty({ - description: '인증된 storage URL (10분 간 유효)', + description: + '인증된 storage URL (10분 간 유효), file이 여러 chunk로 나눠져있음.', example: [ 'https://storage.googleapis.com/...', 'https://storage.googleapis.com/...', diff --git a/src/domain/vr-video/dto/request/generate-vr-video.request.dto.ts b/src/domain/vr-video/dto/request/generate-vr-video.request.dto.ts index f49ed99..e6184d0 100644 --- a/src/domain/vr-video/dto/request/generate-vr-video.request.dto.ts +++ b/src/domain/vr-video/dto/request/generate-vr-video.request.dto.ts @@ -1,15 +1,26 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { VrVideo } from '../../entity/vr-video.entity'; import { ObjectDataType } from '../../type/object-data.type'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsNotEmpty, + IsString, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; -class VrResourceInfo { - @ApiProperty({ description: '리소스 ID', example: '123' }) +export class VrResourceInfo { + @ApiProperty({ + description: '리소스(아바타, 배경) ID', + example: '123456789012', + }) @IsNotEmpty() @IsString() resourceId: string; - @ApiProperty({ description: '리소스 위치 (VR 비디오 내에서의)' }) + @ApiProperty({ description: '리소스 위치 (VR 비디오 내에서의 x,y,z위치 등)' }) @IsNotEmpty() objectData: ObjectDataType; } @@ -26,5 +37,10 @@ export class GenerateVrVideoRequestDto extends PickType(VrVideo, [ type: [VrResourceInfo], }) @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @ArrayMinSize(1) + @ArrayMaxSize(10) + @Type(() => VrResourceInfo) avatarsInfo: VrResourceInfo[]; } diff --git a/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts b/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts index 8d91e11..2a66262 100644 --- a/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts +++ b/src/domain/vr-video/dto/response/get-vr-videos.response.dto.ts @@ -1,20 +1,58 @@ import { ApiProperty, PickType } from '@nestjs/swagger'; import { VrVideo } from '../../entity/vr-video.entity'; -import { VrResourceDto } from 'src/domain/vr-resource/dto/response/get-vr-resources.response.dto'; +import { VrResource } from 'src/domain/vr-resource/entity/vr-resource.entity'; + +export class VrResourceDtoForVideo extends PickType(VrResource, [ + 'id', + 'title', + 'type', + 'createdAt', +] as const) { + // ! storageUrls: vr-resource(아바타) 파일 + @ApiProperty({ + description: 'vr-resource(아바타) 파일: storage URL (10분 간 유효)', + example: [ + 'https://storage.googleapis.com/...', + 'https://storage.googleapis.com/...', + ], + }) + storageUrls: string[]; + + @ApiProperty({ + description: 'video내에서 리소스의 포지셔닝을 설명하는 파일의 storage URL', + example: 'https://storage.googleapis.com/...', + }) + inVideoPositionFile: string; + + static of( + vrResource: VrResource, + storageUrls: string[], + inVideoPositionFile: string, + ): VrResourceDtoForVideo { + return { + id: vrResource.id, + title: vrResource.title, + type: vrResource.type, + storageUrls: storageUrls, + inVideoPositionFile: inVideoPositionFile, + createdAt: vrResource.createdAt, + }; + } +} export class GetVrVideosResponseDto extends PickType(VrVideo, [ 'title', ] as const) { - @ApiProperty({ type: VrResourceDto }) - scene: VrResourceDto; + @ApiProperty({ type: VrResourceDtoForVideo }) + scene: VrResourceDtoForVideo; - @ApiProperty({ type: [VrResourceDto] }) - avatars: VrResourceDto[]; + @ApiProperty({ type: [VrResourceDtoForVideo] }) + avatars: VrResourceDtoForVideo[]; constructor( vrVideo: VrVideo, - scene: VrResourceDto, - avatars: VrResourceDto[], + scene: VrResourceDtoForVideo, + avatars: VrResourceDtoForVideo[], ) { super(vrVideo, ['title', 'scene', 'avatars']); this.scene = scene; diff --git a/src/domain/vr-video/repository/vr-video.repository.ts b/src/domain/vr-video/repository/vr-video.repository.ts index 61fe073..79dac6c 100644 --- a/src/domain/vr-video/repository/vr-video.repository.ts +++ b/src/domain/vr-video/repository/vr-video.repository.ts @@ -2,6 +2,8 @@ import { Repository } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; import { Injectable } from '@nestjs/common'; import { VrVideo } from '../entity/vr-video.entity'; +import { VrResource } from 'src/domain/vr-resource/entity/vr-resource.entity'; +import { Group } from 'src/domain/group/entity/group.entity'; @Injectable() export class VrVideoRepository extends Repository { @@ -18,4 +20,11 @@ export class VrVideoRepository extends Repository { relations: ['scene', 'avatars'], }); } + + async findById(videoId: string): Promise { + return this.repository.findOne({ + where: { id: videoId }, + relations: ['scene', 'avatars'], + }); + } } diff --git a/src/domain/vr-video/service/vr-video.service.ts b/src/domain/vr-video/service/vr-video.service.ts index 8a052c2..d355c67 100644 --- a/src/domain/vr-video/service/vr-video.service.ts +++ b/src/domain/vr-video/service/vr-video.service.ts @@ -1,5 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { GenerateVrVideoRequestDto } from '../dto/request/generate-vr-video.request.dto'; +import { + GenerateVrVideoRequestDto, + VrResourceInfo, +} from '../dto/request/generate-vr-video.request.dto'; import { User } from 'src/domain/user/entity/user.entity'; import { GroupRepository } from 'src/domain/group/repository/group.repository'; import { VrVideoStorageRepository } from 'src/common/gcp/cloud-storage/vr-video-storage.repository'; @@ -9,10 +12,11 @@ import { VrResourceRepository } from 'src/domain/vr-resource/repository/vr-resou import { ObjectDataType } from '../type/object-data.type'; import { VrVideoRepository } from '../repository/vr-video.repository'; import { GroupService } from 'src/domain/group/group.service'; -import { GetVrVideosResponseDto } from '../dto/response/get-vr-videos.response.dto'; -import { VrResourceDto } from 'src/domain/vr-resource/dto/response/get-vr-resources.response.dto'; +import { + GetVrVideosResponseDto, + VrResourceDtoForVideo, +} from '../dto/response/get-vr-videos.response.dto'; import { VrResourceStorageRepository } from 'src/common/gcp/cloud-storage/vr-resource-storage.repository'; -import { NotFoundError } from 'rxjs'; @Injectable() export class VrVideoService { @@ -25,6 +29,66 @@ export class VrVideoService { private readonly vrVideoRepository: VrVideoRepository, ) {} + /* ---------------------------- GET /vr-video/:id --------------------------- */ + + /** + * videoID를 통해 특정 video만 가져옴 + * @param user UserID + * @param videoId videoID + */ + async getVrVideo( + user: User, + videoId: string, + ): Promise { + const vrVideo = await this.vrVideoRepository.findById(videoId); + // 1. validation logic + if (!vrVideo) { + throw new NotFoundException('VrVideo not found'); + } + const isUserInGroup = await this.groupRepository.isUserInGroup( + user.id, + vrVideo.group.id, + ); + if (!isUserInGroup) { + throw new NotFoundException('User is not in the group'); + } + + // 2. return DTO from DB. + const sceneDto = VrResourceDtoForVideo.of( + vrVideo.scene, + await this.vrResourceStorageRepository.generateSignedUrlList( + vrVideo.scene.filePath, + ), + await this.getResourcePositionFileURL( + vrVideo.id, + vrVideo.scene.id, + 'scene', + ), + ); + const avatarDtos = await Promise.all( + vrVideo.avatars.map(async (avatar) => { + return VrResourceDtoForVideo.of( + avatar, + await this.vrResourceStorageRepository.generateSignedUrlList( + avatar.filePath, + ), + await this.getResourcePositionFileURL( + vrVideo.id, + avatar.id, + 'avatar', + ), + ); + }), + ); + return new GetVrVideosResponseDto(vrVideo, sceneDto, avatarDtos); + } + + /* ------------------------------ GET /vr-video ----------------------------- */ + + /** + * @param user userID + * @returns {GetVrVideosResponseDto[]} + */ async getVrVideos(user: User): Promise { const groupId = (await this.groupService.getMyGroup(user)).id; const vrVideos = await this.vrVideoRepository.findByGroupIdWithResources( @@ -33,19 +97,29 @@ export class VrVideoService { return await Promise.all( vrVideos.map(async (vrVideo) => { - const sceneDto = VrResourceDto.of( + const sceneDto = VrResourceDtoForVideo.of( vrVideo.scene, await this.vrResourceStorageRepository.generateSignedUrlList( vrVideo.scene.filePath, ), + await this.getResourcePositionFileURL( + vrVideo.id, + vrVideo.scene.id, + 'scene', + ), ); const avatarDtos = await Promise.all( vrVideo.avatars.map(async (avatar) => { - return VrResourceDto.of( + return VrResourceDtoForVideo.of( avatar, await this.vrResourceStorageRepository.generateSignedUrlList( avatar.filePath, ), + await this.getResourcePositionFileURL( + vrVideo.id, + avatar.id, + 'avatar', + ), ); }), ); @@ -54,6 +128,16 @@ export class VrVideoService { ); } + /* ----------------------------- POST /vr-video ----------------------------- */ + + /** + * DB에는 관계정보(avatar, scene)를 저장하고, + * avatar의 id를 기반으로 cloud storage에 .json파일을 저장. + * (즉, DB에는 videoId는 존재하여도 cloud storage의 filePath는 존재하지 않음.) + * @param user userId + * @param requestDto GenerateVrVideoRequestDto + * @returns {void} + */ async generateVrVideo( user: User, requestDto: GenerateVrVideoRequestDto, @@ -79,34 +163,25 @@ export class VrVideoService { // Save VR Video to main DB. const vrVideo = new VrVideo(); - vrVideo.id = this.generateVrVideoId(user.id); + const vrVideoId = this.generateVrVideoId(user.id); + vrVideo.id = vrVideoId; vrVideo.title = title; vrVideo.scene = scene; vrVideo.avatars = avatars; vrVideo.group = group; await this.vrVideoRepository.save(vrVideo); - // Save to Position Json file Cloud Storage. - await this.vrVideoStorageRepository.uploadFile( - this.convertJsontoJsonFile( - sceneInfo.objectData, - `${sceneInfo.resourceId}.json`, - ), - `${sceneInfo.resourceId}.json`, - ); - await Promise.all( - avatarsInfo.map(async (avatarInfo) => { - await this.vrVideoStorageRepository.uploadFile( - this.convertJsontoJsonFile( - avatarInfo.objectData, - `${avatarInfo.resourceId}.json`, - ), - `${avatarInfo.resourceId}.json`, - ); - }), + await this.uploadVideoPositionToCloudStorage( + vrVideoId, + sceneInfo, + avatarsInfo, ); } + /* -------------------------------------------------------------------------- */ + /* utilitiy functions */ + /* -------------------------------------------------------------------------- */ + private convertJsontoJsonFile( json: ObjectDataType, name: string, @@ -125,4 +200,47 @@ export class VrVideoService { const hash = createHash('sha256').update(data).digest('hex'); return hash; } + + private async getResourcePositionFileURL( + vrVideoId: string, + resourceId: string, + type: 'scene' | 'avatar', + ): Promise { + const signedUrl = await this.vrVideoStorageRepository.generateSignedUrl( + `vr-video/${vrVideoId}/${type}-${resourceId}.json`, + ); + return signedUrl; + } + + /** + * cloud storage에 scene과 avatar의 위치정보를 업로드 + * @filepath `vr-video/${vrVideoId}/scene-${sceneInfo.resourceId}.json` + * @param vrVideoId + * @param sceneInfo + * @param avatarsInfo + */ + private async uploadVideoPositionToCloudStorage( + vrVideoId: string, + sceneInfo: VrResourceInfo, + avatarsInfo: VrResourceInfo[], + ) { + await this.vrVideoStorageRepository.uploadFile( + this.convertJsontoJsonFile( + sceneInfo.objectData, + `${sceneInfo.resourceId}.json`, + ), + `vr-video/${vrVideoId}/scene-${sceneInfo.resourceId}.json`, + ); + await Promise.all( + avatarsInfo.map(async (avatarInfo) => { + await this.vrVideoStorageRepository.uploadFile( + this.convertJsontoJsonFile( + avatarInfo.objectData, + `${avatarInfo.resourceId}.json`, + ), + `vr-video/${vrVideoId}/avatar-${avatarInfo.resourceId}.json`, + ); + }), + ); + } } diff --git a/src/domain/vr-video/vr-video.controller.ts b/src/domain/vr-video/vr-video.controller.ts index dc76e02..12483fa 100644 --- a/src/domain/vr-video/vr-video.controller.ts +++ b/src/domain/vr-video/vr-video.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -19,6 +19,11 @@ import { GetVrVideosResponseDto } from './dto/response/get-vr-videos.response.dt export class VrVideoController { constructor(private readonly vrVideoService: VrVideoService) {} + /** + * + * @param user userID + * @param requestDto scene 1, avatars 1~10 + */ @ApiOperation({ summary: 'VR 비디오 생성 요청' }) @ApiBearerAuth() @UseGuards(JwtAuthGuard, InitEnrollGuard, CareGiverGuard) @@ -38,4 +43,17 @@ export class VrVideoController { async getVrVideos(@AuthUser() user: User): Promise { return await this.vrVideoService.getVrVideos(user); } + + // 특정 VR 비디오 id를 통해서 특정한 VR비디오 정보를 불러오는 API를 작성할것. + @ApiOperation({ summary: 'VR 비디오 정보 불러오기' }) + @ApiBearerAuth() + @ApiResponse({ type: GetVrVideosResponseDto }) + @UseGuards(JwtAuthGuard, InitEnrollGuard) + @Get('/:id') + async getVrVideo( + @AuthUser() user: User, + @Param('id') videoId: string, + ): Promise { + return await this.vrVideoService.getVrVideo(user, videoId); + } }