initial commit
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
import { env } from '../config/env.js';
|
||||
import {
|
||||
getGenerationByUuidForUser,
|
||||
getGenerationSteps,
|
||||
updateGenerationResult,
|
||||
getGenerationMetaByUuid,
|
||||
} from '../repositories/scenario.repository.js';
|
||||
|
||||
/**
|
||||
* Валидация формата результата от n8n
|
||||
*
|
||||
* Ожидаемый формат body (массив файлов):
|
||||
* {
|
||||
* files: [
|
||||
* {
|
||||
* contentType: 'image' | 'video',
|
||||
* url: string, // ссылка на файл (GET запрос)
|
||||
* thumbnailUrl?: string,
|
||||
* duration?: number,
|
||||
* width?: number,
|
||||
* height?: number,
|
||||
* size?: number,
|
||||
* format?: string
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
*/
|
||||
function validateResultBody(body) {
|
||||
const errors = [];
|
||||
|
||||
if (!body || typeof body !== 'object') {
|
||||
errors.push('Body must be an object');
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// files: обязательный массив
|
||||
if (!body.files) {
|
||||
errors.push('files is required');
|
||||
} else if (!Array.isArray(body.files)) {
|
||||
errors.push('files must be an array');
|
||||
} else if (body.files.length === 0) {
|
||||
errors.push('files array cannot be empty');
|
||||
} else {
|
||||
// Валидация каждого файла
|
||||
body.files.forEach((file, index) => {
|
||||
const prefix = `files[${index}]`;
|
||||
|
||||
// contentType
|
||||
if (!file.contentType) {
|
||||
errors.push(`${prefix}.contentType is required`);
|
||||
} else if (!['image', 'video'].includes(file.contentType)) {
|
||||
errors.push(`${prefix}.contentType must be 'image' or 'video'`);
|
||||
}
|
||||
|
||||
// url: обязателен
|
||||
if (!file.url) {
|
||||
errors.push(`${prefix}.url is required`);
|
||||
} else if (typeof file.url !== 'string' || file.url.trim() === '') {
|
||||
errors.push(`${prefix}.url must be a non-empty string`);
|
||||
} else {
|
||||
try {
|
||||
new URL(file.url);
|
||||
} catch {
|
||||
errors.push(`${prefix}.url must be a valid URL`);
|
||||
}
|
||||
}
|
||||
|
||||
// thumbnailUrl: опционален
|
||||
if (file.thumbnailUrl !== undefined && file.thumbnailUrl !== null) {
|
||||
if (typeof file.thumbnailUrl !== 'string') {
|
||||
errors.push(`${prefix}.thumbnailUrl must be a string`);
|
||||
} else {
|
||||
try {
|
||||
new URL(file.thumbnailUrl);
|
||||
} catch {
|
||||
errors.push(`${prefix}.thumbnailUrl must be a valid URL`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// duration: опционален, только для видео
|
||||
if (file.duration !== undefined && file.duration !== null) {
|
||||
if (typeof file.duration !== 'number') {
|
||||
errors.push(`${prefix}.duration must be a number`);
|
||||
} else if (file.duration < 0) {
|
||||
errors.push(`${prefix}.duration must be >= 0`);
|
||||
}
|
||||
}
|
||||
|
||||
// width/height: опциональны
|
||||
if (file.width !== undefined && file.width !== null) {
|
||||
if (!Number.isInteger(file.width) || file.width <= 0) {
|
||||
errors.push(`${prefix}.width must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.height !== undefined && file.height !== null) {
|
||||
if (!Number.isInteger(file.height) || file.height <= 0) {
|
||||
errors.push(`${prefix}.height must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
// size: опционален
|
||||
if (file.size !== undefined && file.size !== null) {
|
||||
if (typeof file.size !== 'number' || file.size < 0) {
|
||||
errors.push(`${prefix}.size must be a non-negative number`);
|
||||
}
|
||||
}
|
||||
|
||||
// format: опционален
|
||||
if (file.format !== undefined && file.format !== null) {
|
||||
if (typeof file.format !== 'string' || file.format.trim() === '') {
|
||||
errors.push(`${prefix}.format must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить результат генерации для пользователя
|
||||
* Формат ответа для фронта:
|
||||
* {
|
||||
* generationUuid: string,
|
||||
* scenarioId: string,
|
||||
* scenarioName: string,
|
||||
* status: 'running' | 'completed' | 'failed' | 'waiting_for_input',
|
||||
* files: Array<{
|
||||
* id: string,
|
||||
* contentType: 'image' | 'video',
|
||||
* url: string,
|
||||
* thumbnailUrl?: string,
|
||||
* duration?: number,
|
||||
* width?: number,
|
||||
* height?: number,
|
||||
* size?: number,
|
||||
* format?: string
|
||||
* }>,
|
||||
* steps: [...],
|
||||
* startedAt: date,
|
||||
* finishedAt: date
|
||||
* }
|
||||
*/
|
||||
export async function getResultForUser({ userId, generationUuid }) {
|
||||
const generation = await getGenerationByUuidForUser(generationUuid, userId);
|
||||
|
||||
if (!generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps = await getGenerationSteps(generationUuid);
|
||||
|
||||
// Преобразуем result_payload в формат для фронта
|
||||
const files = parseResultPayload(generation.result_payload);
|
||||
|
||||
return {
|
||||
generationUuid: generation.generation_uuid,
|
||||
scenarioId: generation.scenario_id,
|
||||
scenarioName: generation.scenario_name,
|
||||
status: generation.status,
|
||||
files,
|
||||
steps,
|
||||
startedAt: generation.started_at,
|
||||
finishedAt: generation.finished_at,
|
||||
requestPayload: generation.request_payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить результат в БД
|
||||
*/
|
||||
export async function saveResultToDB(generationUuid, files) {
|
||||
const resultPayload = { files };
|
||||
return updateGenerationResult(generationUuid, resultPayload, 'completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить метаданные генерации (только scenarioId и scenarioName)
|
||||
*/
|
||||
export async function getGenerationMeta({ userId, generationUuid }) {
|
||||
const generation = await getGenerationMetaByUuid(generationUuid, userId);
|
||||
|
||||
if (!generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
scenarioId: generation.scenario_id,
|
||||
scenarioName: generation.scenario_name,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит result_payload в массив файлов
|
||||
*/
|
||||
function parseResultPayload(resultPayload) {
|
||||
if (!resultPayload || !resultPayload.files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return resultPayload.files.map((file, index) => ({
|
||||
id: `file-${index}`,
|
||||
contentType: file.contentType,
|
||||
url: file.url,
|
||||
thumbnailUrl: file.thumbnailUrl,
|
||||
duration: file.duration,
|
||||
width: file.width,
|
||||
height: file.height,
|
||||
size: file.size,
|
||||
format: file.format,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработать результат от n8n
|
||||
*/
|
||||
export async function processResultFromN8n({ generationUuid, userId, scenarioId, resultBody }) {
|
||||
// Проверяем, что generation существует и принадлежит пользователю
|
||||
const generation = await getGenerationByUuidForUser(generationUuid, userId);
|
||||
|
||||
if (!generation) {
|
||||
const error = new Error(`Generation '${generationUuid}' not found or does not belong to user '${userId}'`);
|
||||
error.code = 'GENERATION_NOT_FOUND';
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Валидация результата
|
||||
const validation = validateResultBody(resultBody);
|
||||
if (!validation.valid) {
|
||||
const error = new Error(`Invalid result format: ${validation.errors.join(', ')}`);
|
||||
error.code = 'INVALID_RESULT_FORMAT';
|
||||
error.status = 400;
|
||||
error.details = validation.errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Сохраняем результат в БД
|
||||
const updatedGeneration = await updateGenerationResult(generationUuid, resultBody, 'completed');
|
||||
|
||||
return {
|
||||
generationUuid: updatedGeneration.generation_uuid,
|
||||
status: updatedGeneration.status,
|
||||
files: parseResultPayload(updatedGeneration.result_payload),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Вызов n8n webhook для получения результата
|
||||
* stepData - данные из response_payload шага (taskId, recordId и т.д.)
|
||||
*/
|
||||
export async function fetchResultFromN8n({ generationUuid, userId, scenarioId, stepData = {} }) {
|
||||
// Вызываем webhook без generationUuid в пути (передаём в meta/body)
|
||||
const n8nUrl = `${env.N8N_BASE_URL}/webhook/result`;
|
||||
|
||||
const payload = {
|
||||
meta: {
|
||||
generationUuid,
|
||||
userId,
|
||||
scenarioId,
|
||||
stepData,
|
||||
},
|
||||
body: stepData,
|
||||
};
|
||||
|
||||
console.log('[fetchResultFromN8n] Calling:', n8nUrl);
|
||||
console.log('[fetchResultFromN8n] Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
try {
|
||||
const response = await fetch(n8nUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
console.log('[fetchResultFromN8n] Response status:', response.status, response.statusText);
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('[fetchResultFromN8n] Response body:', responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`n8n webhook failed: ${response.status} ${response.statusText}`);
|
||||
error.code = 'N8N_WEBHOOK_ERROR';
|
||||
error.status = response.status;
|
||||
error.n8nResponse = responseText;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = JSON.parse(responseText);
|
||||
console.log('[fetchResultFromN8n] Parsed data:', data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error('[fetchResultFromN8n] Error:', err.message);
|
||||
if (err.code === 'N8N_WEBHOOK_ERROR') {
|
||||
throw err;
|
||||
}
|
||||
const error = new Error(`Failed to call n8n webhook: ${err.message}`);
|
||||
error.code = 'N8N_CONNECTION_ERROR';
|
||||
error.status = 503;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user