generated from fahricansecer/boilerplate-be
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EventsGateway } from '../events/events.gateway';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { ProjectStatus, RenderStage } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Render Callback Controller
|
||||
*
|
||||
* C# Media Worker, render ilerlemesini bu endpoint'ler aracılığıyla bildirir.
|
||||
* Public endpoint — JWT gerekmez, bunun yerine API key ile korunur.
|
||||
*
|
||||
* Flow: C# Worker → HTTP POST → Bu controller → EventsGateway → Frontend (WebSocket)
|
||||
*/
|
||||
@ApiTags('render-callback')
|
||||
@Controller('render-callback')
|
||||
export class RenderCallbackController {
|
||||
private readonly logger = new Logger(RenderCallbackController.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly eventsGateway: EventsGateway,
|
||||
private readonly db: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {
|
||||
this.apiKey = this.configService.get<string>('RENDER_CALLBACK_API_KEY', 'contgen-worker-secret-2026');
|
||||
}
|
||||
|
||||
/**
|
||||
* API key doğrulaması — C# Worker'ın kimliğini kontrol eder.
|
||||
*/
|
||||
private validateApiKey(authHeader?: string) {
|
||||
const key = authHeader?.replace('Bearer ', '');
|
||||
if (key !== this.apiKey) {
|
||||
throw new UnauthorizedException('Geçersiz worker API key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render ilerleme bildirimi.
|
||||
* C# Worker her aşamada bu endpoint'i çağırır.
|
||||
*/
|
||||
@Public()
|
||||
@Post('progress')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Render ilerleme bildirimi (Worker → API)' })
|
||||
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
|
||||
@ApiResponse({ status: 200, description: 'İlerleme kaydedildi' })
|
||||
async reportProgress(
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Body() body: RenderProgressDto,
|
||||
) {
|
||||
this.validateApiKey(authHeader);
|
||||
|
||||
this.logger.log(
|
||||
`Progress: ${body.projectId} — %${body.progress} [${body.stage}] sahne ${body.currentScene}/${body.totalScenes}`,
|
||||
);
|
||||
|
||||
// DB'de proje progress'ini güncelle
|
||||
await this.db.project.update({
|
||||
where: { id: body.projectId },
|
||||
data: {
|
||||
progress: body.progress,
|
||||
status: this.mapStageToStatus(body.stage) as ProjectStatus,
|
||||
},
|
||||
});
|
||||
|
||||
// RenderJob log yaz
|
||||
if (body.renderJobId) {
|
||||
await this.db.renderLog.create({
|
||||
data: {
|
||||
renderJobId: body.renderJobId,
|
||||
stage: body.stage as RenderStage,
|
||||
level: 'INFO',
|
||||
message: body.stageLabel || `${body.stage} — %${body.progress}`,
|
||||
durationMs: body.stepDurationMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket ile frontend'e bildir
|
||||
this.eventsGateway.emitRenderProgress(body.projectId, {
|
||||
progress: body.progress,
|
||||
stage: body.stage,
|
||||
stageLabel: body.stageLabel,
|
||||
currentScene: body.currentScene,
|
||||
totalScenes: body.totalScenes,
|
||||
eta: body.eta,
|
||||
});
|
||||
|
||||
return { status: 'ok', progress: body.progress };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render tamamlandı bildirimi.
|
||||
*/
|
||||
@Public()
|
||||
@Post('completed')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Render tamamlandı bildirimi (Worker → API)' })
|
||||
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
|
||||
async reportCompleted(
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Body() body: RenderCompletedDto,
|
||||
) {
|
||||
this.validateApiKey(authHeader);
|
||||
|
||||
this.logger.log(
|
||||
`COMPLETED: ${body.projectId} — video: ${body.finalVideoUrl} (${body.processingTimeMs}ms)`,
|
||||
);
|
||||
|
||||
// Proje durumunu güncelle
|
||||
await this.db.project.update({
|
||||
where: { id: body.projectId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
progress: 100,
|
||||
finalVideoUrl: body.finalVideoUrl,
|
||||
thumbnailUrl: body.thumbnailUrl || null,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// RenderJob durumunu güncelle
|
||||
if (body.renderJobId) {
|
||||
await this.db.renderJob.update({
|
||||
where: { id: body.renderJobId },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
finalVideoUrl: body.finalVideoUrl,
|
||||
processingTimeMs: body.processingTimeMs,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.renderLog.create({
|
||||
data: {
|
||||
renderJobId: body.renderJobId,
|
||||
stage: 'FINALIZATION' as RenderStage,
|
||||
level: 'INFO',
|
||||
message: `Video tamamlandı — ${body.processingTimeMs}ms, ${body.fileSize} bytes`,
|
||||
durationMs: body.processingTimeMs,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket ile frontend'e bildir
|
||||
this.eventsGateway.emitRenderCompleted(body.projectId, {
|
||||
finalVideoUrl: body.finalVideoUrl,
|
||||
thumbnailUrl: body.thumbnailUrl,
|
||||
processingTimeMs: body.processingTimeMs,
|
||||
fileSize: body.fileSize,
|
||||
});
|
||||
|
||||
this.eventsGateway.emitProjectStatusChanged(body.projectId, 'COMPLETED');
|
||||
|
||||
// Proje sahibine in-app bildirim gönder
|
||||
const project = await this.db.project.findUnique({
|
||||
where: { id: body.projectId },
|
||||
select: { userId: true, title: true },
|
||||
});
|
||||
if (project) {
|
||||
await this.notificationsService.createNotification({
|
||||
userId: project.userId,
|
||||
type: 'render_complete',
|
||||
title: 'Video hazır! 🎬',
|
||||
message: `"${project.title}" videonuz başarıyla oluşturuldu.`,
|
||||
metadata: {
|
||||
projectId: body.projectId,
|
||||
renderJobId: body.renderJobId,
|
||||
finalVideoUrl: body.finalVideoUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 'ok', projectId: body.projectId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render hatası bildirimi.
|
||||
*/
|
||||
@Public()
|
||||
@Post('failed')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Render hatası bildirimi (Worker → API)' })
|
||||
@ApiHeader({ name: 'Authorization', description: 'Bearer {WORKER_API_KEY}' })
|
||||
async reportFailed(
|
||||
@Headers('authorization') authHeader: string,
|
||||
@Body() body: RenderFailedDto,
|
||||
) {
|
||||
this.validateApiKey(authHeader);
|
||||
|
||||
this.logger.error(
|
||||
`FAILED: ${body.projectId} — ${body.error} [${body.stage}] attempt: ${body.attemptNumber}`,
|
||||
);
|
||||
|
||||
// Proje durumunu güncelle
|
||||
await this.db.project.update({
|
||||
where: { id: body.projectId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage: body.error,
|
||||
},
|
||||
});
|
||||
|
||||
// RenderJob durumunu güncelle
|
||||
if (body.renderJobId) {
|
||||
await this.db.renderJob.update({
|
||||
where: { id: body.renderJobId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
errorMessage: body.error,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.db.renderLog.create({
|
||||
data: {
|
||||
renderJobId: body.renderJobId,
|
||||
stage: body.stage as RenderStage,
|
||||
level: 'ERROR',
|
||||
message: body.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket ile frontend'e bildir
|
||||
this.eventsGateway.emitRenderFailed(body.projectId, {
|
||||
error: body.error,
|
||||
stage: body.stage,
|
||||
attemptNumber: body.attemptNumber,
|
||||
canRetry: body.canRetry ?? true,
|
||||
});
|
||||
|
||||
this.eventsGateway.emitProjectStatusChanged(body.projectId, 'FAILED');
|
||||
|
||||
// Proje sahibine in-app bildirim gönder
|
||||
const project = await this.db.project.findUnique({
|
||||
where: { id: body.projectId },
|
||||
select: { userId: true, title: true },
|
||||
});
|
||||
if (project) {
|
||||
await this.notificationsService.createNotification({
|
||||
userId: project.userId,
|
||||
type: 'render_failed',
|
||||
title: 'Video oluşturulamadı ⚠️',
|
||||
message: `"${project.title}" işlenirken hata oluştu: ${body.error}`,
|
||||
metadata: {
|
||||
projectId: body.projectId,
|
||||
renderJobId: body.renderJobId,
|
||||
stage: body.stage,
|
||||
canRetry: body.canRetry ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { status: 'ok', projectId: body.projectId };
|
||||
}
|
||||
|
||||
private mapStageToStatus(stage: string): string {
|
||||
switch (stage) {
|
||||
case 'tts':
|
||||
case 'image_generation':
|
||||
case 'music_generation':
|
||||
return 'GENERATING_MEDIA';
|
||||
case 'compositing':
|
||||
case 'encoding':
|
||||
return 'RENDERING';
|
||||
default:
|
||||
return 'GENERATING_MEDIA';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── DTO Tanımları ──────────────────────────────────────────
|
||||
|
||||
class RenderProgressDto {
|
||||
projectId: string;
|
||||
renderJobId?: string;
|
||||
progress: number;
|
||||
stage: string;
|
||||
stageLabel: string;
|
||||
currentScene?: number;
|
||||
totalScenes?: number;
|
||||
eta?: number;
|
||||
stepDurationMs?: number;
|
||||
}
|
||||
|
||||
class RenderCompletedDto {
|
||||
projectId: string;
|
||||
renderJobId?: string;
|
||||
finalVideoUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
processingTimeMs: number;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
class RenderFailedDto {
|
||||
projectId: string;
|
||||
renderJobId?: string;
|
||||
error: string;
|
||||
stage: string;
|
||||
attemptNumber: number;
|
||||
canRetry?: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user