main
Backend Deploy 🚀 / build-and-deploy (push) Has been cancelled

This commit is contained in:
Harun CAN
2026-03-30 00:21:32 +03:00
parent 85c35c73e8
commit acb103657b
29 changed files with 11473 additions and 13081 deletions
@@ -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;
}